@slicedearth/whoisleuth-cli 2.3.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +19 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +68 -6
- package/cli/artifact-validation/case-response.mjs +47 -41
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +83 -61
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +39 -31
- package/cli/command-argument-grammar.mjs +4 -0
- package/cli/command-reference.mjs +145 -75
- package/cli/completion.mjs +298 -79
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +44 -16
- package/cli/discovery-scan-command-runner.mjs +2 -0
- package/cli/discovery-scan.mjs +7 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +1 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +4 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +45 -0
- package/cli/runner.mjs +55 -6
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +76 -30
- package/docs/cli.md +289 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +43 -3
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +36 -23
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +7 -2
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +219 -118
- package/lib/domain-control-manifest.mjs +117 -57
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +88 -6
- package/lib/domain-posture.mjs +113 -26
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -592
- package/lib/evidence-report.mjs +3 -1
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +82 -85
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +7 -10
- package/lib/lookup-source-progress.mjs +17 -14
- package/lib/lookup.mjs +48 -27
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +56 -18
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +17 -3
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +10 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +117 -146
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +111 -71
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +59 -25
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-core.mjs +3 -2
- package/packages/cases/case-record-operations.mjs +89 -27
- package/packages/cases/case-record-projection.mjs +23 -20
- package/packages/cases/case-report.mjs +63 -52
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +41 -1129
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +38 -587
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +25 -0
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +4 -1
- package/packages/comparison/page-similarity.mjs +2 -1
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +279 -116
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +1 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +0 -1
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +160 -225
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +155 -27
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +89 -49
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +161 -141
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +158 -20
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +11 -5
- package/packages/workspace/bulk-session-model.mjs +95 -20
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -26
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +70 -30
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +85 -21
- package/third-party-notices.txt +29 -2
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { normalizeDomain, openOrCreateCase, updateCase, } from "../cases/case-model.mjs";
|
|
2
2
|
import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
|
|
3
3
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { canonicalArtifactJsonV2 } from "../evidence/artifact-integrity.mjs";
|
|
5
|
+
import { sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
6
|
+
import { MAX_RESPONSE_LABEL_LENGTH, MAX_RESPONSE_VALUE_LENGTH, MAX_RESPONSE_LIMITATION_LENGTH, normalizeCaseEvidencePins, normalizeCaseSightings, } from "../cases/case-response-model.mjs";
|
|
7
|
+
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, } from "../contracts/external-observation-interchange.mjs";
|
|
5
8
|
import { hasUnsafeRetainedText } from "./retained-text.mjs";
|
|
6
|
-
export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
|
|
9
|
+
export { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDING_LIMITATIONS, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, MAX_EXTERNAL_FINDINGS, MAX_EXTERNAL_FINDINGS_IMPORT_BYTES, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, };
|
|
7
10
|
export const EXTERNAL_FINDING_CATEGORIES = [
|
|
8
11
|
'certificate',
|
|
9
12
|
'dns',
|
|
@@ -83,14 +86,31 @@ function iso(value, label, optional = false) {
|
|
|
83
86
|
function limitations(value, index) {
|
|
84
87
|
if (value === undefined)
|
|
85
88
|
return [];
|
|
86
|
-
if (!Array.isArray(value) || value.length >
|
|
87
|
-
throw new Error(`Finding ${index + 1} limitations must be an array with no more than
|
|
89
|
+
if (!Array.isArray(value) || value.length > MAX_EXTERNAL_FINDING_LIMITATIONS) {
|
|
90
|
+
throw new Error(`Finding ${index + 1} limitations must be an array with no more than ${MAX_EXTERNAL_FINDING_LIMITATIONS} entries.`);
|
|
88
91
|
}
|
|
89
92
|
const unique = new Set();
|
|
90
93
|
for (const item of value)
|
|
91
|
-
unique.add(requiredText(item,
|
|
94
|
+
unique.add(requiredText(item, MAX_EXTERNAL_FINDING_LIMITATION_LENGTH, `Finding ${index + 1} limitation`));
|
|
92
95
|
return [...unique];
|
|
93
96
|
}
|
|
97
|
+
/** Keep provenance and omissions visible within the existing import bound. */
|
|
98
|
+
export function retainExternalFindingLimitations(supplied, mandatory) {
|
|
99
|
+
const required = limitations(mandatory, 0);
|
|
100
|
+
const optional = limitations(supplied, 0).filter((entry) => !required.includes(entry));
|
|
101
|
+
const available = MAX_EXTERNAL_FINDING_LIMITATIONS - required.length;
|
|
102
|
+
if (optional.length <= available)
|
|
103
|
+
return Object.freeze([...required, ...optional]);
|
|
104
|
+
if (available < 1)
|
|
105
|
+
throw new TypeError('Mandatory finding limitations leave no room to disclose omitted supplied limitations.');
|
|
106
|
+
const retained = optional.slice(0, available - 1);
|
|
107
|
+
const omitted = optional.length - retained.length;
|
|
108
|
+
return Object.freeze([
|
|
109
|
+
...required,
|
|
110
|
+
...retained,
|
|
111
|
+
`${omitted} supplied limitation${omitted === 1 ? ' was' : 's were'} omitted to retain mandatory provenance and omission notices; review the supplied input for the remaining qualifications.`,
|
|
112
|
+
]);
|
|
113
|
+
}
|
|
94
114
|
function structuredObservation(value, index) {
|
|
95
115
|
if (value === undefined || value === null)
|
|
96
116
|
return null;
|
|
@@ -267,21 +287,19 @@ function importedSourceLabel(finding, sourceName) {
|
|
|
267
287
|
? `Deployment observation: ${sourceName}`
|
|
268
288
|
: `Provider report: ${sourceName}`;
|
|
269
289
|
}
|
|
270
|
-
function
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
&&
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
&& pin.sourceSchema.version === observation.sourceVersion
|
|
284
|
-
&& (observation.eventId === null || pin.certificateObservation?.eventId === observation.eventId))))) ? findingKey(finding, sourceName) : '';
|
|
290
|
+
function alreadyRetained(recordValue, projection) {
|
|
291
|
+
const { importContentSha256: expectedIdentity, ...expected } = projection.evidencePin;
|
|
292
|
+
return recordValue.evidencePins.some((pin) => {
|
|
293
|
+
const { id: _id, createdAt: _createdAt, importContentSha256, ...material } = pin;
|
|
294
|
+
if (importContentSha256 && importContentSha256 !== expectedIdentity)
|
|
295
|
+
return false;
|
|
296
|
+
// Older pins have no content digest. Only a complete, exact retained
|
|
297
|
+
// projection can establish duplication; a shortened prefix cannot.
|
|
298
|
+
if (!importContentSha256 && (projection.shortenedFields.length || projection.omittedLimitations))
|
|
299
|
+
return false;
|
|
300
|
+
// An imported digest alone cannot substitute for matching retained fields.
|
|
301
|
+
return canonicalArtifactJsonV2(material) === canonicalArtifactJsonV2(expected);
|
|
302
|
+
});
|
|
285
303
|
}
|
|
286
304
|
function structuredPinFields(finding) {
|
|
287
305
|
const observation = finding.structuredObservation;
|
|
@@ -313,37 +331,41 @@ function structuredPinFields(finding) {
|
|
|
313
331
|
certificateObservation,
|
|
314
332
|
};
|
|
315
333
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
334
|
+
/** Exact material retained by both the import preview and Case mutation. */
|
|
335
|
+
export function externalFindingCaseProjection(finding, source) {
|
|
336
|
+
const identitySha256 = sha256IdentityHex(new TextEncoder().encode(canonicalArtifactJsonV2({
|
|
337
|
+
sourceName: source.name, sourceReference: source.reference, finding,
|
|
338
|
+
})));
|
|
339
|
+
const value = finding.structuredObservation?.value ?? pinValue(finding);
|
|
340
|
+
const sourceLabel = importedSourceLabel(finding, source.name);
|
|
341
|
+
const sourceReference = source.reference ? `Source reference: ${source.reference}` : null;
|
|
342
|
+
const shortenedFields = [
|
|
343
|
+
...(value.length > MAX_RESPONSE_VALUE_LENGTH ? ['finding value'] : []),
|
|
344
|
+
...(sourceLabel.length > MAX_RESPONSE_LABEL_LENGTH ? ['source label'] : []),
|
|
345
|
+
...(sourceReference && sourceReference.length > MAX_RESPONSE_LIMITATION_LENGTH ? ['source reference'] : []),
|
|
346
|
+
];
|
|
347
|
+
const mandatory = [
|
|
324
348
|
finding.evidenceClass === 'deployment_observation'
|
|
325
349
|
? `Imported as an observation made by ${source.name}; this browser session did not collect or independently verify it.`
|
|
326
350
|
: `Reported by ${source.name}; WHOISleuth did not collect or independently verify this provider finding.`,
|
|
327
|
-
...(
|
|
328
|
-
|
|
351
|
+
...(sourceReference ? [sourceReference.length > MAX_RESPONSE_LIMITATION_LENGTH
|
|
352
|
+
? `${sourceReference.slice(0, MAX_RESPONSE_LIMITATION_LENGTH - 13)}… [shortened]` : sourceReference] : []),
|
|
353
|
+
...(shortenedFields.length ? [`Case storage shortened ${shortenedFields.join(', ')}. The content digest covers the complete accepted finding.`] : []),
|
|
329
354
|
];
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
355
|
+
const sourceLimitations = retainExternalFindingLimitations(finding.limitations, mandatory);
|
|
356
|
+
const omittedLimitations = finding.limitations.filter((item) => !sourceLimitations.includes(item)).length;
|
|
357
|
+
const pin = normalizeCaseEvidencePins([{
|
|
333
358
|
...structuredPinFields(finding),
|
|
359
|
+
importContentSha256: identitySha256,
|
|
334
360
|
label: `External ${finding.category} finding`,
|
|
335
|
-
value
|
|
336
|
-
source:
|
|
361
|
+
value,
|
|
362
|
+
source: sourceLabel,
|
|
337
363
|
observedAt: finding.observedAt,
|
|
338
364
|
completeness: finding.completeness,
|
|
365
|
+
truncated: shortenedFields.length || omittedLimitations ? true : null,
|
|
339
366
|
limitations: sourceLimitations,
|
|
340
|
-
},
|
|
341
|
-
|
|
342
|
-
const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
|
|
343
|
-
if (!addedPin)
|
|
344
|
-
throw new Error('The imported finding could not be retained as Case evidence.');
|
|
345
|
-
updated = updateCase(updated.cases, target.id, {
|
|
346
|
-
sighting: {
|
|
367
|
+
}], finding.observedAt)[0];
|
|
368
|
+
const observed = normalizeCaseSightings([{
|
|
347
369
|
state: finding.evidenceClass === 'deployment_observation'
|
|
348
370
|
? 'observed_by_deployment'
|
|
349
371
|
: 'reported_by_provider',
|
|
@@ -359,9 +381,28 @@ function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
|
|
|
359
381
|
source: source.name,
|
|
360
382
|
observedAt: finding.observedAt,
|
|
361
383
|
completeness: finding.completeness,
|
|
362
|
-
evidencePinId: addedPin.id,
|
|
363
384
|
limitations: sourceLimitations,
|
|
364
|
-
},
|
|
385
|
+
}], finding.observedAt)[0];
|
|
386
|
+
if (!pin || !observed)
|
|
387
|
+
throw new TypeError('The finding has no valid retained Case projection.');
|
|
388
|
+
const { id: _pinId, createdAt: _pinCreatedAt, ...evidencePin } = pin;
|
|
389
|
+
const { id: _sightingId, createdAt: _sightingCreatedAt, evidencePinId: _pinReference, ...sighting } = observed;
|
|
390
|
+
return { evidencePin, sighting, identitySha256, shortenedFields, omittedLimitations };
|
|
391
|
+
}
|
|
392
|
+
function mergeExternalFindingIntoCase(current, caseId, finding, source, now) {
|
|
393
|
+
const target = current.find((candidate) => candidate.id === caseId) ?? null;
|
|
394
|
+
if (!target)
|
|
395
|
+
throw new Error('The selected Case is unavailable for this finding.');
|
|
396
|
+
const projection = externalFindingCaseProjection(finding, source);
|
|
397
|
+
if (alreadyRetained(target, projection))
|
|
398
|
+
return { cases: [...current], record: target, added: false };
|
|
399
|
+
const existingPinIds = new Set(target.evidencePins.map((pin) => pin.id));
|
|
400
|
+
let updated = updateCase([...current], target.id, { evidencePin: projection.evidencePin }, now);
|
|
401
|
+
const addedPin = updated.record.evidencePins.find((pin) => !existingPinIds.has(pin.id)) ?? null;
|
|
402
|
+
if (!addedPin)
|
|
403
|
+
throw new Error('The imported finding could not be retained as Case evidence.');
|
|
404
|
+
updated = updateCase(updated.cases, target.id, {
|
|
405
|
+
sighting: { ...projection.sighting, evidencePinId: addedPin.id },
|
|
365
406
|
}, now);
|
|
366
407
|
return { cases: updated.cases, record: updated.record, added: true };
|
|
367
408
|
}
|
|
@@ -372,7 +413,6 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
|
|
|
372
413
|
let findingsAdded = 0;
|
|
373
414
|
let duplicatesSkipped = 0;
|
|
374
415
|
for (const finding of document.findings) {
|
|
375
|
-
const existing = cases.find((candidate) => candidate.domain === finding.domain) ?? null;
|
|
376
416
|
const opened = openOrCreateCase(cases, { domain: finding.domain, source: 'import' }, now);
|
|
377
417
|
cases = opened.cases;
|
|
378
418
|
const target = cases.find((candidate) => candidate.id === opened.record.id) ?? opened.record;
|
|
@@ -383,10 +423,10 @@ export function mergeExternalFindingsIntoCases(current, document, now = new Date
|
|
|
383
423
|
continue;
|
|
384
424
|
}
|
|
385
425
|
findingsAdded += 1;
|
|
386
|
-
if (
|
|
387
|
-
updatedDomains.add(finding.domain);
|
|
388
|
-
else
|
|
426
|
+
if (opened.created)
|
|
389
427
|
createdDomains.add(finding.domain);
|
|
428
|
+
else
|
|
429
|
+
updatedDomains.add(finding.domain);
|
|
390
430
|
}
|
|
391
431
|
return {
|
|
392
432
|
cases,
|
|
@@ -422,7 +462,7 @@ export function externalFindingsCaseTargets(document, caseDomain) {
|
|
|
422
462
|
}
|
|
423
463
|
/**
|
|
424
464
|
* Merges already-validated external findings into one selected Case. A Case is
|
|
425
|
-
*
|
|
465
|
+
* identified by its immutable ID with a registrable-domain pivot, while rendered captures may retain an exact
|
|
426
466
|
* hostname. Only same-domain findings are accepted, the exact hostname remains
|
|
427
467
|
* explicit in the retained evidence, and this operation can never open another
|
|
428
468
|
* Case as a side effect.
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import { normalizeDomain } from "../cases/case-model.mjs";
|
|
2
|
+
import { EXTERNAL_FINDINGS_SCHEMA, EXTERNAL_FINDINGS_VERSION, MAX_EXTERNAL_FINDING_DOMAINS, MAX_EXTERNAL_FINDINGS_PER_DOMAIN, MAX_EXTERNAL_FINDING_SUMMARY_LENGTH, parseExternalFindingsDocument, } from "./external-findings-import.mjs";
|
|
3
|
+
import { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
|
|
4
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
5
|
+
import { readCaptureConditions, readObservationLabel } from "../comparison/capture-context.mjs";
|
|
6
|
+
export { WEB_CAPTURE_MANIFEST_SCHEMA, WEB_CAPTURE_MANIFEST_VERSION, WEB_CAPTURE_SUMMARY_SCHEMA, WEB_CAPTURE_SUMMARY_VERSION, } from "../contracts/web-capture.mjs";
|
|
7
|
+
export const MAX_WEB_CAPTURE_SUMMARIES = 50;
|
|
8
|
+
export { MAX_WEB_CAPTURE_DOM_DIGEST_BYTES, MAX_WEB_CAPTURE_SCREENSHOT_BYTES } from "../contracts/web-capture.mjs";
|
|
9
|
+
const MAX_CAPTURE_TECHNOLOGIES = 20;
|
|
10
|
+
const MAX_CAPTURE_ORIGINS = 30;
|
|
11
|
+
const SHA256_RE = /^[a-f0-9]{64}$/i;
|
|
12
|
+
const PERCEPTUAL_HASH_RE = /^[a-f0-9]{16}$/i;
|
|
13
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/u;
|
|
14
|
+
const ROOT_KEYS = new Set(['schema', 'schemaVersion', 'source', 'captures']);
|
|
15
|
+
const SOURCE_KEYS = new Set(['name', 'reference', 'collectedAt']);
|
|
16
|
+
const CAPTURE_KEYS = new Set([
|
|
17
|
+
'domain',
|
|
18
|
+
'capturedAt',
|
|
19
|
+
'completeness',
|
|
20
|
+
'limitations',
|
|
21
|
+
'pageTitle',
|
|
22
|
+
'finalOrigin',
|
|
23
|
+
'screenshotSha256',
|
|
24
|
+
'technologies',
|
|
25
|
+
'networkOrigins',
|
|
26
|
+
]);
|
|
27
|
+
const MANIFEST_CAPTURE_KEYS = new Set([
|
|
28
|
+
'conditions', 'observerLabel', 'vantageLabel',
|
|
29
|
+
'domain',
|
|
30
|
+
'capturedAt',
|
|
31
|
+
'completeness',
|
|
32
|
+
'limitations',
|
|
33
|
+
'page',
|
|
34
|
+
'requestDomains',
|
|
35
|
+
'technologies',
|
|
36
|
+
'artifacts',
|
|
37
|
+
]);
|
|
38
|
+
const PAGE_KEYS = new Set(['title', 'finalOrigin']);
|
|
39
|
+
const ARTIFACT_KEYS = new Set(['kind', 'fileName', 'mimeType', 'sha256', 'perceptualHash', 'bytes', 'width', 'height']);
|
|
40
|
+
function record(value) {
|
|
41
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
42
|
+
? value
|
|
43
|
+
: null;
|
|
44
|
+
}
|
|
45
|
+
function onlyKeys(value, allowed) {
|
|
46
|
+
return Object.keys(value).every((key) => allowed.has(key));
|
|
47
|
+
}
|
|
48
|
+
function text(value, maximum, label, optional = false) {
|
|
49
|
+
if (optional && (value === undefined || value === null || value === ''))
|
|
50
|
+
return null;
|
|
51
|
+
if (typeof value !== 'string' || !value.trim() || value.length > maximum || CONTROL_RE.test(value)) {
|
|
52
|
+
throw new Error(`${label} must be bounded text without control characters.`);
|
|
53
|
+
}
|
|
54
|
+
return value.replace(/\s+/g, ' ').trim();
|
|
55
|
+
}
|
|
56
|
+
function timestamp(value, label, optional = false) {
|
|
57
|
+
const candidate = text(value, 64, label, optional);
|
|
58
|
+
if (candidate === null)
|
|
59
|
+
return null;
|
|
60
|
+
const normalized = normalizeExplicitIsoTimestamp(candidate);
|
|
61
|
+
if (!normalized)
|
|
62
|
+
throw new Error(`${label} must be a valid date and time with an explicit timezone.`);
|
|
63
|
+
return normalized;
|
|
64
|
+
}
|
|
65
|
+
function origin(value, label) {
|
|
66
|
+
if (value === undefined || value === null || value === '')
|
|
67
|
+
return null;
|
|
68
|
+
const candidate = text(value, 500, label);
|
|
69
|
+
try {
|
|
70
|
+
const parsed = new URL(candidate ?? '');
|
|
71
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password)
|
|
72
|
+
throw new Error();
|
|
73
|
+
if (parsed.pathname !== '/' || parsed.search || parsed.hash)
|
|
74
|
+
throw new Error();
|
|
75
|
+
return parsed.origin.toLowerCase();
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
throw new Error(`${label} must be an HTTP(S) origin without credentials, path, query, or fragment.`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function stringList(value, maximumItems, maximumLength, label) {
|
|
82
|
+
if (value === undefined)
|
|
83
|
+
return [];
|
|
84
|
+
if (!Array.isArray(value) || value.length > maximumItems)
|
|
85
|
+
throw new Error(`${label} exceeds its item bound.`);
|
|
86
|
+
const output = new Set();
|
|
87
|
+
for (const item of value) {
|
|
88
|
+
const normalized = text(item, maximumLength, label);
|
|
89
|
+
if (normalized)
|
|
90
|
+
output.add(normalized);
|
|
91
|
+
}
|
|
92
|
+
return [...output];
|
|
93
|
+
}
|
|
94
|
+
export function parseWebCaptureSummary(value) {
|
|
95
|
+
const root = record(value);
|
|
96
|
+
if (!root
|
|
97
|
+
|| !onlyKeys(root, ROOT_KEYS)
|
|
98
|
+
|| root.schema !== WEB_CAPTURE_SUMMARY_SCHEMA
|
|
99
|
+
|| root.schemaVersion !== WEB_CAPTURE_SUMMARY_VERSION) {
|
|
100
|
+
throw new Error(`Web captures must use ${WEB_CAPTURE_SUMMARY_SCHEMA} schema version ${WEB_CAPTURE_SUMMARY_VERSION}.`);
|
|
101
|
+
}
|
|
102
|
+
const source = record(root.source);
|
|
103
|
+
if (!source || !onlyKeys(source, SOURCE_KEYS))
|
|
104
|
+
throw new Error('Web captures require a bounded source object.');
|
|
105
|
+
const sourceName = text(source.name, 80, 'Capture source name');
|
|
106
|
+
const sourceReference = text(source.reference, 500, 'Capture source reference', true);
|
|
107
|
+
const sourceCollectedAt = timestamp(source.collectedAt, 'Capture source collection time', true);
|
|
108
|
+
if (!Array.isArray(root.captures) || !root.captures.length || root.captures.length > MAX_WEB_CAPTURE_SUMMARIES) {
|
|
109
|
+
throw new Error(`Web captures must contain between 1 and ${MAX_WEB_CAPTURE_SUMMARIES} summaries.`);
|
|
110
|
+
}
|
|
111
|
+
const findings = [];
|
|
112
|
+
const domainCounts = new Map();
|
|
113
|
+
for (const [index, raw] of root.captures.entries()) {
|
|
114
|
+
const capture = record(raw);
|
|
115
|
+
if (!capture || !onlyKeys(capture, CAPTURE_KEYS))
|
|
116
|
+
throw new Error(`Web capture ${index + 1} contains unsupported fields.`);
|
|
117
|
+
const domain = normalizeDomain(text(capture.domain, 253, `Web capture ${index + 1} domain`));
|
|
118
|
+
if (!domain)
|
|
119
|
+
throw new Error(`Web capture ${index + 1} domain is invalid.`);
|
|
120
|
+
const domainCount = (domainCounts.get(domain) ?? 0) + 1;
|
|
121
|
+
if (domainCount > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
|
|
122
|
+
throw new Error(`Web captures exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-summary per-domain limit.`);
|
|
123
|
+
}
|
|
124
|
+
domainCounts.set(domain, domainCount);
|
|
125
|
+
if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
|
|
126
|
+
throw new Error(`Web captures exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
|
|
127
|
+
}
|
|
128
|
+
const observedAt = timestamp(capture.capturedAt, `Web capture ${index + 1} time`);
|
|
129
|
+
const completeness = ['complete', 'inconclusive', 'partial', 'unknown'].includes(String(capture.completeness))
|
|
130
|
+
? capture.completeness
|
|
131
|
+
: 'unknown';
|
|
132
|
+
const limitations = stringList(capture.limitations, 8, 240, `Web capture ${index + 1} limitations`);
|
|
133
|
+
const pageTitle = text(capture.pageTitle, 300, `Web capture ${index + 1} title`, true);
|
|
134
|
+
const finalOrigin = origin(capture.finalOrigin, `Web capture ${index + 1} final origin`);
|
|
135
|
+
const technologies = stringList(capture.technologies, MAX_CAPTURE_TECHNOLOGIES, 80, `Web capture ${index + 1} technologies`);
|
|
136
|
+
const networkOrigins = stringList(capture.networkOrigins, MAX_CAPTURE_ORIGINS, 500, `Web capture ${index + 1} network origins`)
|
|
137
|
+
.map((item, originIndex) => origin(item, `Web capture ${index + 1} network origin ${originIndex + 1}`))
|
|
138
|
+
.filter((item) => item !== null);
|
|
139
|
+
const screenshotSha256 = capture.screenshotSha256 === undefined || capture.screenshotSha256 === null || capture.screenshotSha256 === ''
|
|
140
|
+
? null
|
|
141
|
+
: typeof capture.screenshotSha256 === 'string' && SHA256_RE.test(capture.screenshotSha256)
|
|
142
|
+
? capture.screenshotSha256.toLowerCase()
|
|
143
|
+
: (() => { throw new Error(`Web capture ${index + 1} screenshot digest must be SHA-256.`); })();
|
|
144
|
+
const summaries = [
|
|
145
|
+
pageTitle || finalOrigin
|
|
146
|
+
? `Sanitised page capture${pageTitle ? ` titled "${pageTitle}"` : ''}${finalOrigin ? ` ended at origin ${finalOrigin}` : ''}.`
|
|
147
|
+
: '',
|
|
148
|
+
technologies.length ? `Observed technology labels: ${technologies.join(', ')}.` : '',
|
|
149
|
+
networkOrigins.length ? `Observed network origins: ${networkOrigins.join(', ')}.` : '',
|
|
150
|
+
screenshotSha256 ? `Screenshot SHA-256: ${screenshotSha256}.` : '',
|
|
151
|
+
].filter(Boolean);
|
|
152
|
+
if (!summaries.length)
|
|
153
|
+
throw new Error(`Web capture ${index + 1} contains no supported summary evidence.`);
|
|
154
|
+
findings.push({
|
|
155
|
+
domain,
|
|
156
|
+
category: technologies.length && summaries.length === 1 ? 'http' : 'page',
|
|
157
|
+
evidenceClass: 'deployment_observation',
|
|
158
|
+
summary: summaries.join(' '),
|
|
159
|
+
observedAt,
|
|
160
|
+
completeness,
|
|
161
|
+
limitations: [
|
|
162
|
+
'Imported sanitised capture summary; WHOISleuth did not collect or independently verify this observation.',
|
|
163
|
+
...limitations,
|
|
164
|
+
].slice(0, 8),
|
|
165
|
+
reference: sourceReference,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
return parseExternalFindingsDocument({
|
|
169
|
+
schema: EXTERNAL_FINDINGS_SCHEMA,
|
|
170
|
+
schemaVersion: EXTERNAL_FINDINGS_VERSION,
|
|
171
|
+
source: { name: sourceName, reference: sourceReference, collectedAt: sourceCollectedAt },
|
|
172
|
+
findings,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
function artifactName(value, label) {
|
|
176
|
+
const candidate = text(value, 120, label) ?? '';
|
|
177
|
+
if (candidate === '.'
|
|
178
|
+
|| candidate === '..'
|
|
179
|
+
|| candidate.includes('/')
|
|
180
|
+
|| candidate.includes('\\')
|
|
181
|
+
|| candidate.includes('%2f')
|
|
182
|
+
|| candidate.includes('%5c')) {
|
|
183
|
+
throw new Error(`${label} must be a plain file name without a path.`);
|
|
184
|
+
}
|
|
185
|
+
return candidate;
|
|
186
|
+
}
|
|
187
|
+
function positiveInteger(value, maximum, label) {
|
|
188
|
+
if (!Number.isInteger(value) || Number(value) <= 0 || Number(value) > maximum) {
|
|
189
|
+
throw new Error(`${label} is outside the supported bound.`);
|
|
190
|
+
}
|
|
191
|
+
return Number(value);
|
|
192
|
+
}
|
|
193
|
+
function listSummaryFragments(label, values) {
|
|
194
|
+
const fragments = [];
|
|
195
|
+
let current = `${label}:`;
|
|
196
|
+
for (const value of values) {
|
|
197
|
+
const separator = current === `${label}:` ? ' ' : ', ';
|
|
198
|
+
if (`${current}${separator}${value}.`.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
|
|
199
|
+
fragments.push(`${current}.`);
|
|
200
|
+
current = `${label}: ${value}`;
|
|
201
|
+
}
|
|
202
|
+
else
|
|
203
|
+
current += `${separator}${value}`;
|
|
204
|
+
}
|
|
205
|
+
if (current !== `${label}:`)
|
|
206
|
+
fragments.push(`${current}.`);
|
|
207
|
+
return fragments;
|
|
208
|
+
}
|
|
209
|
+
function partitionSummary(fragments) {
|
|
210
|
+
const summaries = [];
|
|
211
|
+
let current = '';
|
|
212
|
+
for (const fragment of fragments) {
|
|
213
|
+
if (!fragment || fragment.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
|
|
214
|
+
throw new Error('Web capture metadata cannot be represented within the external-finding summary bound.');
|
|
215
|
+
}
|
|
216
|
+
const combined = current ? `${current} ${fragment}` : fragment;
|
|
217
|
+
if (combined.length > MAX_EXTERNAL_FINDING_SUMMARY_LENGTH) {
|
|
218
|
+
summaries.push(current);
|
|
219
|
+
current = fragment;
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
current = combined;
|
|
223
|
+
}
|
|
224
|
+
if (current)
|
|
225
|
+
summaries.push(current);
|
|
226
|
+
return summaries;
|
|
227
|
+
}
|
|
228
|
+
export function readWebCaptureManifest(value) {
|
|
229
|
+
const root = record(value);
|
|
230
|
+
if (!root
|
|
231
|
+
|| !onlyKeys(root, ROOT_KEYS)
|
|
232
|
+
|| root.schema !== WEB_CAPTURE_MANIFEST_SCHEMA
|
|
233
|
+
|| root.schemaVersion !== WEB_CAPTURE_MANIFEST_VERSION) {
|
|
234
|
+
throw new Error(`Web capture manifests must use ${WEB_CAPTURE_MANIFEST_SCHEMA} schema version ${WEB_CAPTURE_MANIFEST_VERSION}.`);
|
|
235
|
+
}
|
|
236
|
+
const source = record(root.source);
|
|
237
|
+
if (!source || !onlyKeys(source, SOURCE_KEYS))
|
|
238
|
+
throw new Error('Web capture manifests require a bounded source object.');
|
|
239
|
+
const sourceName = text(source.name, 80, 'Capture source name');
|
|
240
|
+
const sourceReference = text(source.reference, 500, 'Capture source reference', true);
|
|
241
|
+
const sourceCollectedAt = timestamp(source.collectedAt, 'Capture source collection time', true);
|
|
242
|
+
if (!Array.isArray(root.captures) || !root.captures.length || root.captures.length > MAX_WEB_CAPTURE_SUMMARIES) {
|
|
243
|
+
throw new Error(`Web capture manifests must contain between 1 and ${MAX_WEB_CAPTURE_SUMMARIES} captures.`);
|
|
244
|
+
}
|
|
245
|
+
const findings = [];
|
|
246
|
+
const artifacts = [];
|
|
247
|
+
const contexts = [];
|
|
248
|
+
const domainCounts = new Map();
|
|
249
|
+
const findingCounts = new Map();
|
|
250
|
+
for (const [index, raw] of root.captures.entries()) {
|
|
251
|
+
const capture = record(raw);
|
|
252
|
+
if (!capture || !onlyKeys(capture, MANIFEST_CAPTURE_KEYS)) {
|
|
253
|
+
throw new Error(`Web capture manifest ${index + 1} contains unsupported fields or archive content.`);
|
|
254
|
+
}
|
|
255
|
+
const domain = normalizeDomain(text(capture.domain, 253, `Web capture manifest ${index + 1} domain`));
|
|
256
|
+
if (!domain)
|
|
257
|
+
throw new Error(`Web capture manifest ${index + 1} domain is invalid.`);
|
|
258
|
+
const domainCount = (domainCounts.get(domain) ?? 0) + 1;
|
|
259
|
+
if (domainCount > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
|
|
260
|
+
throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-capture per-domain limit.`);
|
|
261
|
+
}
|
|
262
|
+
domainCounts.set(domain, domainCount);
|
|
263
|
+
if (domainCounts.size > MAX_EXTERNAL_FINDING_DOMAINS) {
|
|
264
|
+
throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDING_DOMAINS}-domain limit.`);
|
|
265
|
+
}
|
|
266
|
+
const observedAt = timestamp(capture.capturedAt, `Web capture manifest ${index + 1} time`);
|
|
267
|
+
const conditions = readCaptureConditions(capture.conditions);
|
|
268
|
+
const observerLabel = readObservationLabel(capture.observerLabel), vantageLabel = readObservationLabel(capture.vantageLabel);
|
|
269
|
+
const completeness = ['complete', 'inconclusive', 'partial', 'unknown'].includes(String(capture.completeness))
|
|
270
|
+
? capture.completeness
|
|
271
|
+
: 'unknown';
|
|
272
|
+
const limitations = stringList(capture.limitations, 8, 240, `Web capture manifest ${index + 1} limitations`);
|
|
273
|
+
contexts.push({ domain, observedAt, completeness: String(completeness), conditions, observerLabel, vantageLabel });
|
|
274
|
+
const page = record(capture.page);
|
|
275
|
+
if (page && !onlyKeys(page, PAGE_KEYS))
|
|
276
|
+
throw new Error(`Web capture manifest ${index + 1} page metadata contains unsupported fields.`);
|
|
277
|
+
const pageTitle = text(page?.title, 300, `Web capture manifest ${index + 1} title`, true);
|
|
278
|
+
const finalOrigin = origin(page?.finalOrigin, `Web capture manifest ${index + 1} final origin`);
|
|
279
|
+
const technologies = stringList(capture.technologies, MAX_CAPTURE_TECHNOLOGIES, 80, `Web capture manifest ${index + 1} technologies`);
|
|
280
|
+
const requestDomains = stringList(capture.requestDomains, MAX_CAPTURE_ORIGINS, 253, `Web capture manifest ${index + 1} request domains`)
|
|
281
|
+
.map((item) => normalizeDomain(item))
|
|
282
|
+
.filter((item) => Boolean(item));
|
|
283
|
+
if (!Array.isArray(capture.artifacts) || !capture.artifacts.length || capture.artifacts.length > 2) {
|
|
284
|
+
throw new Error(`Web capture manifest ${index + 1} must contain one or two artifact metadata records.`);
|
|
285
|
+
}
|
|
286
|
+
const artifactSummaries = [];
|
|
287
|
+
const kinds = new Set();
|
|
288
|
+
for (const [artifactIndex, rawArtifact] of capture.artifacts.entries()) {
|
|
289
|
+
const artifact = record(rawArtifact);
|
|
290
|
+
if (!artifact || !onlyKeys(artifact, ARTIFACT_KEYS)) {
|
|
291
|
+
throw new Error(`Web capture manifest ${index + 1} artifact ${artifactIndex + 1} contains unsupported fields.`);
|
|
292
|
+
}
|
|
293
|
+
const kind = artifact.kind === 'screenshot' || artifact.kind === 'dom_digest' ? artifact.kind : null;
|
|
294
|
+
if (!kind || kinds.has(kind))
|
|
295
|
+
throw new Error(`Web capture manifest ${index + 1} has an invalid or duplicate artifact kind.`);
|
|
296
|
+
kinds.add(kind);
|
|
297
|
+
const fileName = artifactName(artifact.fileName, `Web capture manifest ${index + 1} artifact name`);
|
|
298
|
+
const sha256 = typeof artifact.sha256 === 'string' && SHA256_RE.test(artifact.sha256)
|
|
299
|
+
? artifact.sha256.toLowerCase()
|
|
300
|
+
: null;
|
|
301
|
+
if (!sha256)
|
|
302
|
+
throw new Error(`Web capture manifest ${index + 1} artifact digest must be SHA-256.`);
|
|
303
|
+
const mimeType = text(artifact.mimeType, 80, `Web capture manifest ${index + 1} artifact MIME type`)?.toLowerCase();
|
|
304
|
+
const maximumBytes = kind === 'screenshot' ? MAX_WEB_CAPTURE_SCREENSHOT_BYTES : MAX_WEB_CAPTURE_DOM_DIGEST_BYTES;
|
|
305
|
+
const bytes = positiveInteger(artifact.bytes, maximumBytes, `Web capture manifest ${index + 1} artifact size`);
|
|
306
|
+
if (kind === 'screenshot') {
|
|
307
|
+
if (!['image/png', 'image/jpeg', 'image/webp'].includes(mimeType || '')) {
|
|
308
|
+
throw new Error(`Web capture manifest ${index + 1} screenshot MIME type is unsupported.`);
|
|
309
|
+
}
|
|
310
|
+
const width = positiveInteger(artifact.width, 10_000, `Web capture manifest ${index + 1} screenshot width`);
|
|
311
|
+
const height = positiveInteger(artifact.height, 10_000, `Web capture manifest ${index + 1} screenshot height`);
|
|
312
|
+
const perceptualHash = artifact.perceptualHash === undefined || artifact.perceptualHash === null || artifact.perceptualHash === ''
|
|
313
|
+
? null
|
|
314
|
+
: typeof artifact.perceptualHash === 'string' && PERCEPTUAL_HASH_RE.test(artifact.perceptualHash)
|
|
315
|
+
? artifact.perceptualHash.toLowerCase()
|
|
316
|
+
: (() => { throw new Error(`Web capture manifest ${index + 1} screenshot perceptual hash is unsupported.`); })();
|
|
317
|
+
artifactSummaries.push(`Screenshot ${fileName}: ${mimeType}, ${width}x${height}, ${bytes} bytes, SHA-256 ${sha256}${perceptualHash ? `, dHash ${perceptualHash}` : ''}.`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
if (mimeType !== 'application/json')
|
|
321
|
+
throw new Error(`Web capture manifest ${index + 1} DOM digest MIME type is unsupported.`);
|
|
322
|
+
if (artifact.width !== undefined || artifact.height !== undefined || artifact.perceptualHash !== undefined) {
|
|
323
|
+
throw new Error(`Web capture manifest ${index + 1} DOM digest cannot declare image-only fields.`);
|
|
324
|
+
}
|
|
325
|
+
artifactSummaries.push(`DOM digest ${fileName}: application/json, ${bytes} bytes, SHA-256 ${sha256}.`);
|
|
326
|
+
}
|
|
327
|
+
artifacts.push({ capture: index + 1, observedAt, kind, fileName, mimeType: mimeType, sha256, bytes });
|
|
328
|
+
}
|
|
329
|
+
const summaryFragments = [
|
|
330
|
+
...(conditions ? [`Declared capture conditions: ${conditions.browser} ${conditions.browserVersion}; viewport ${conditions.viewport.width}x${conditions.viewport.height}; scale ${conditions.deviceScaleFactor}; locale ${conditions.locale}; timezone ${conditions.timezone}; ${conditions.colourScheme}.`] : []),
|
|
331
|
+
...(observerLabel || vantageLabel ? [`Declared observer: ${observerLabel ?? 'unknown'}; vantage: ${vantageLabel ?? 'unknown'}. These labels do not verify collection independence.`] : []),
|
|
332
|
+
pageTitle || finalOrigin
|
|
333
|
+
? `Sanitised page capture${pageTitle ? ` titled "${pageTitle}"` : ''}${finalOrigin ? ` ended at origin ${finalOrigin}` : ''}.`
|
|
334
|
+
: '',
|
|
335
|
+
...listSummaryFragments('Observed technology labels', technologies),
|
|
336
|
+
...listSummaryFragments('Observed request domains', requestDomains),
|
|
337
|
+
...artifactSummaries,
|
|
338
|
+
].filter(Boolean);
|
|
339
|
+
const summaries = partitionSummary(summaryFragments);
|
|
340
|
+
const priorFindingCount = findingCounts.get(domain) ?? 0;
|
|
341
|
+
if (priorFindingCount + summaries.length > MAX_EXTERNAL_FINDINGS_PER_DOMAIN) {
|
|
342
|
+
throw new Error(`Web capture manifests exceed the ${MAX_EXTERNAL_FINDINGS_PER_DOMAIN}-finding per-domain import limit after preserving bounded metadata.`);
|
|
343
|
+
}
|
|
344
|
+
findingCounts.set(domain, priorFindingCount + summaries.length);
|
|
345
|
+
for (const summary of summaries) {
|
|
346
|
+
findings.push({
|
|
347
|
+
domain,
|
|
348
|
+
category: 'page',
|
|
349
|
+
evidenceClass: 'deployment_observation',
|
|
350
|
+
summary,
|
|
351
|
+
observedAt,
|
|
352
|
+
completeness,
|
|
353
|
+
limitations: [
|
|
354
|
+
'Imported capture metadata, not independently collected website evidence; artefact bytes and separate byte checks are not retained in these findings.',
|
|
355
|
+
...limitations,
|
|
356
|
+
].slice(0, 8),
|
|
357
|
+
reference: sourceReference,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const document = parseExternalFindingsDocument({
|
|
362
|
+
schema: EXTERNAL_FINDINGS_SCHEMA,
|
|
363
|
+
schemaVersion: EXTERNAL_FINDINGS_VERSION,
|
|
364
|
+
source: { name: sourceName, reference: sourceReference, collectedAt: sourceCollectedAt },
|
|
365
|
+
findings,
|
|
366
|
+
});
|
|
367
|
+
return { document, artifacts, captures: contexts };
|
|
368
|
+
}
|
|
369
|
+
export function parseWebCaptureManifest(value) {
|
|
370
|
+
return readWebCaptureManifest(value).document;
|
|
371
|
+
}
|
|
372
|
+
// Candidates must describe bytes already read and hashed by the caller. File
|
|
373
|
+
// names and MIME declarations never establish identity; equal copies are all
|
|
374
|
+
// reported rather than choosing an arbitrary file.
|
|
375
|
+
export function matchCaptureArtifacts(artifacts, candidates) {
|
|
376
|
+
return artifacts.map(artifact => {
|
|
377
|
+
const matchingIds = candidates.filter(candidate => candidate.bytes === artifact.bytes
|
|
378
|
+
&& candidate.sha256.replace(/^sha256:/u, '') === artifact.sha256)
|
|
379
|
+
.map(candidate => candidate.id);
|
|
380
|
+
return { capture: artifact.capture, kind: artifact.kind, state: matchingIds.length ? 'matched' : 'not_found', matchingIds };
|
|
381
|
+
});
|
|
382
|
+
}
|
|
@@ -178,7 +178,8 @@ export function buildBulkMailExposureReport(rowsRaw, options = {}) {
|
|
|
178
178
|
...relation(state, profile, profileSourceState, profileContextComparable, profileContextLimitation),
|
|
179
179
|
mutationTypes: item.mutationTypes.slice(0, 40),
|
|
180
180
|
registration: item.availability,
|
|
181
|
-
|
|
181
|
+
// The published review retains source states; full source clocks stay in the workspace.
|
|
182
|
+
sourceCoverage: item.sourceCoverage.map(({ source, state }) => ({ source, state })),
|
|
182
183
|
profileContextState: item.profileContext.sourceState,
|
|
183
184
|
profileContextLimitation,
|
|
184
185
|
limitations: rowLimitations(item, state, profileSourceState, profileContextComparable, profileContextLimitation),
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { prepareBagItEntries, encodeBagItEntries } from "../interchange/bagit.mjs";
|
|
2
|
+
import { prepareInvestigationPackageEntries, INVESTIGATION_PACKAGE_MANIFEST_PATH } from "./investigation-package.mjs";
|
|
3
|
+
/** The existing manifest keeps source declarations; BagIt preserves the bytes. */
|
|
4
|
+
export async function prepareInvestigationBagIt(input, generatedAt, version) {
|
|
5
|
+
const prepared = await prepareInvestigationPackageEntries(input, generatedAt, version);
|
|
6
|
+
const payload = new Map([...prepared.files].filter(([path]) => path !== INVESTIGATION_PACKAGE_MANIFEST_PATH)
|
|
7
|
+
.map(([path, bytes]) => [`data/${path.slice('artifacts/'.length)}`, bytes]));
|
|
8
|
+
const tags = new Map([['whoisleuth-manifest.json', prepared.files.get(INVESTIGATION_PACKAGE_MANIFEST_PATH)]]);
|
|
9
|
+
return { manifest: prepared.manifest, files: await prepareBagItEntries(payload, tags) };
|
|
10
|
+
}
|
|
11
|
+
export async function buildInvestigationBagIt(input, generatedAt, version) {
|
|
12
|
+
const prepared = await prepareInvestigationBagIt(input, generatedAt, version);
|
|
13
|
+
return { manifest: prepared.manifest, bytes: encodeBagItEntries(prepared.files) };
|
|
14
|
+
}
|