@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,7 +1,8 @@
|
|
|
1
|
+
import { LOOKUP_EVIDENCE_SCHEMA } from "../contracts/lookup-evidence.mjs";
|
|
1
2
|
import { canonicalArtifactJsonV2, sha256ArtifactDigestV2, SORTED_JSON_V2, } from "../evidence/artifact-integrity.mjs";
|
|
2
3
|
import { LOOKUP_ASSET_GRAPH_SCHEMA, } from "./lookup-asset-graph.mjs";
|
|
3
4
|
import { LOOKUP_INVESTIGATION_BRIEF_SCHEMA, LOOKUP_INVESTIGATION_BRIEF_VERSION, } from "./lookup-investigation-brief.mjs";
|
|
4
|
-
import { INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA, INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION, INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, PUBLIC_INVESTIGATION_CAPSULE_VERSION, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, } from "../contracts/investigation-portability.mjs";
|
|
5
|
+
import { INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA, INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION, INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, PUBLIC_INVESTIGATION_CAPSULE_VERSION, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, investigationCapsuleContracts, } from "../contracts/investigation-portability.mjs";
|
|
5
6
|
export { INVESTIGATION_CAPSULE_ANALYST_RECORDS_SCHEMA, INVESTIGATION_CAPSULE_ANALYST_RECORDS_VERSION, INVESTIGATION_CAPSULE_SCHEMA, INVESTIGATION_CAPSULE_VERSION, PUBLIC_INVESTIGATION_CAPSULE_VERSION, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, };
|
|
6
7
|
function safeVersion(value) {
|
|
7
8
|
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)
|
|
@@ -45,7 +46,21 @@ function analystProjection(record) {
|
|
|
45
46
|
})),
|
|
46
47
|
};
|
|
47
48
|
}
|
|
49
|
+
/** Links admitted projections; this does not authenticate their observations. */
|
|
50
|
+
export function investigationCapsuleProjectionsMatch(target, brief, graph) {
|
|
51
|
+
const targets = graph.nodes.filter((node) => node.kind === 'target');
|
|
52
|
+
return target.value === brief.target && target.type === brief.targetType
|
|
53
|
+
&& targets.length === 1 && targets[0]?.id === graph.targetId
|
|
54
|
+
&& targets[0]?.label === target.value
|
|
55
|
+
&& brief.relationships.nodes === graph.nodes.length
|
|
56
|
+
&& brief.relationships.edges === graph.edges.length
|
|
57
|
+
&& brief.relationships.truncated === graph.truncated;
|
|
58
|
+
}
|
|
48
59
|
export async function buildInvestigationCapsule(input) {
|
|
60
|
+
const target = { value: input.brief.target, type: input.brief.targetType };
|
|
61
|
+
if (!investigationCapsuleProjectionsMatch(target, input.brief, input.graph)) {
|
|
62
|
+
throw new TypeError('The investigation brief and graph must describe the same target and retained relationships.');
|
|
63
|
+
}
|
|
49
64
|
const analystRecords = input.includeAnalystRecords ? analystProjection(input.caseRecord) : null;
|
|
50
65
|
const [evidenceDigest, briefDigest, graphDigest, analystRecordsDigest] = await Promise.all([
|
|
51
66
|
sha256ArtifactDigestV2(input.lookupEvidence),
|
|
@@ -60,7 +75,7 @@ export async function buildInvestigationCapsule(input) {
|
|
|
60
75
|
schemaVersion: INVESTIGATION_CAPSULE_VERSION,
|
|
61
76
|
generatedAt: iso(input.generatedAt),
|
|
62
77
|
application: { name: 'WHOISleuth', version: safeVersion(input.applicationVersion) },
|
|
63
|
-
target
|
|
78
|
+
target,
|
|
64
79
|
sourceContracts: [
|
|
65
80
|
{ id: 'lookup-evidence', schema: evidenceSchema, version: evidenceVersion, digest: evidenceDigest, embedded: false },
|
|
66
81
|
{ id: 'investigation-brief', schema: LOOKUP_INVESTIGATION_BRIEF_SCHEMA, version: LOOKUP_INVESTIGATION_BRIEF_VERSION, digest: briefDigest, embedded: true },
|
|
@@ -100,11 +115,24 @@ export async function buildInvestigationCapsule(input) {
|
|
|
100
115
|
},
|
|
101
116
|
};
|
|
102
117
|
}
|
|
118
|
+
/** Cross-file linkage after the caller has bounded and hashed the source file.
|
|
119
|
+
* This is neither a source-format validator nor an authenticity decision. */
|
|
120
|
+
export function investigationCapsuleSourceIdentity(target, reference, source) {
|
|
121
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
122
|
+
const expected = object(reference);
|
|
123
|
+
const capsuleTarget = object(target);
|
|
124
|
+
const sourceQuery = object(source.query);
|
|
125
|
+
const validReference = expected?.id === 'lookup-evidence' && expected.embedded === false;
|
|
126
|
+
const schema = validReference && expected.schema === LOOKUP_EVIDENCE_SCHEMA && expected.schema === source.schema;
|
|
127
|
+
const version = validReference && Number.isSafeInteger(expected.version) && expected.version === source.version;
|
|
128
|
+
const targetMatches = typeof capsuleTarget?.value === 'string' && capsuleTarget.value.length > 0
|
|
129
|
+
&& capsuleTarget.value === sourceQuery?.submitted && capsuleTarget.type === sourceQuery.type;
|
|
130
|
+
const contentDigest = validReference && typeof source.digest === 'string' && /^sha256:[a-f0-9]{64}$/u.test(source.digest) && expected.digest === source.digest;
|
|
131
|
+
return Object.freeze({ schema, version, target: targetMatches, contentDigest, linked: schema && version && targetMatches && contentDigest });
|
|
132
|
+
}
|
|
103
133
|
export async function verifyInvestigationCapsule(capsule) {
|
|
104
|
-
const current = capsule.schemaVersion === INVESTIGATION_CAPSULE_VERSION;
|
|
105
|
-
const publicVersion = capsule.schemaVersion === PUBLIC_INVESTIGATION_CAPSULE_VERSION;
|
|
106
134
|
const integrityRecord = capsule.integrity;
|
|
107
|
-
if ((
|
|
135
|
+
if (!investigationCapsuleContracts(capsule.schemaVersion)
|
|
108
136
|
|| integrityRecord.canonicalization !== SORTED_JSON_V2
|
|
109
137
|
|| integrityRecord.scope !== 'capsule excluding integrity') {
|
|
110
138
|
return { valid: false, brief: false, graph: false, analystRecords: null, whole: false };
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } from "../../lib/bounded-json.mjs";
|
|
2
|
+
import { normalizeBoundedSemanticVersion } from "../../lib/semantic-version.mjs";
|
|
3
|
+
import { canonicalArtifactJsonV2, sha256ArtifactBytes, sha256ArtifactDigestV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
4
|
+
import { array, digest, enumeration, exact, fail, integer, iso, strings, text, validateIntegrity } from "../evidence/artifact-structure.mjs";
|
|
5
|
+
import { SELECTED_FILE_MEDIA_TYPES as INVESTIGATION_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
|
|
6
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
7
|
+
export { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, INVESTIGATION_FILE_MEDIA_TYPES };
|
|
8
|
+
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
9
|
+
export const INVESTIGATION_MANIFEST_VERSION = 3;
|
|
10
|
+
export const SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS = [2, INVESTIGATION_MANIFEST_VERSION];
|
|
11
|
+
// Independently bounds the path-free metadata, not the selected file content.
|
|
12
|
+
import { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
13
|
+
export { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
14
|
+
/** A filename suggests a declaration only; it never validates file contents. */
|
|
15
|
+
export function investigationFileMediaType(name) {
|
|
16
|
+
const extension = name.toLowerCase().split('.').at(-1);
|
|
17
|
+
switch (extension) {
|
|
18
|
+
case 'json': return 'application/json';
|
|
19
|
+
case 'png': return 'image/png';
|
|
20
|
+
case 'jpg':
|
|
21
|
+
case 'jpeg': return 'image/jpeg';
|
|
22
|
+
case 'webp': return 'image/webp';
|
|
23
|
+
case 'gif': return 'image/gif';
|
|
24
|
+
case 'pdf': return 'application/pdf';
|
|
25
|
+
default: return 'application/octet-stream';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function sourceDeclaration(source) {
|
|
29
|
+
const identity = source?.identity ?? null;
|
|
30
|
+
const observedAt = source?.observedAt ?? null;
|
|
31
|
+
if (identity !== null)
|
|
32
|
+
text(identity, 'Artefact source identity', 240);
|
|
33
|
+
iso(observedAt, 'Artefact source observation time', true);
|
|
34
|
+
return Object.freeze({ identity, observedAt });
|
|
35
|
+
}
|
|
36
|
+
export function investigationJsonMetadata(content, maximumBytes = MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES) {
|
|
37
|
+
const value = parseBoundedJson(content, {
|
|
38
|
+
label: 'Manifest artefact', maximumBytes,
|
|
39
|
+
limits: boundedJsonLimitsForBytes(maximumBytes),
|
|
40
|
+
});
|
|
41
|
+
const object = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
42
|
+
let schema = null;
|
|
43
|
+
// Arbitrary valid JSON remains packageable. An unusable schema declaration
|
|
44
|
+
// is not copied into metadata; the exact source bytes still retain it.
|
|
45
|
+
if (typeof object?.schema === 'string') {
|
|
46
|
+
try {
|
|
47
|
+
schema = text(object.schema, 'Artefact schema', 160);
|
|
48
|
+
}
|
|
49
|
+
catch { /* Unknown marker; not source-format admission. */ }
|
|
50
|
+
}
|
|
51
|
+
const candidate = object?.version ?? object?.schemaVersion;
|
|
52
|
+
const version = Number.isSafeInteger(candidate) && Number(candidate) > 0 && Number(candidate) <= 1_000 ? Number(candidate) : null;
|
|
53
|
+
return { value, object, schema, version };
|
|
54
|
+
}
|
|
55
|
+
export async function prepareInvestigationManifest(input, generatedAt, applicationVersionValue) {
|
|
56
|
+
if (!Array.isArray(input.artifacts) || input.artifacts.length < 1 || input.artifacts.length > MAX_INVESTIGATION_MANIFEST_ARTIFACTS) {
|
|
57
|
+
throw new TypeError(`A manifest requires between 1 and ${MAX_INVESTIGATION_MANIFEST_ARTIFACTS} artefacts.`);
|
|
58
|
+
}
|
|
59
|
+
iso(generatedAt, 'Manifest generatedAt');
|
|
60
|
+
const workflow = text(input.workflow, 'Manifest workflow', 160);
|
|
61
|
+
const applicationVersion = normalizeBoundedSemanticVersion(applicationVersionValue, 'Application');
|
|
62
|
+
const configurationDigestSha256 = input.configurationDigestSha256;
|
|
63
|
+
if (configurationDigestSha256 !== null)
|
|
64
|
+
digest(configurationDigestSha256, 'configurationDigestSha256');
|
|
65
|
+
let totalBytes = 0;
|
|
66
|
+
// Capture all mutable bytes and declarations before the first asynchronous
|
|
67
|
+
// digest. The package writer uses these same owned bytes, not the caller's view.
|
|
68
|
+
const selected = input.artifacts.map((artifact, index) => {
|
|
69
|
+
if (typeof artifact.content !== 'string' && !(artifact.content instanceof Uint8Array))
|
|
70
|
+
throw new TypeError(`Artefact ${index + 1} requires text or bytes.`);
|
|
71
|
+
if (artifact.content.length > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES || artifact.content.length > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - totalBytes) {
|
|
72
|
+
throw new TypeError('Manifest artefacts exceed the per-file or combined limit.');
|
|
73
|
+
}
|
|
74
|
+
const bytes = typeof artifact.content === 'string' ? new TextEncoder().encode(artifact.content) : new Uint8Array(artifact.content);
|
|
75
|
+
if (bytes.byteLength < 1 || bytes.byteLength > MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES)
|
|
76
|
+
throw new TypeError(`Artefact ${index + 1} must be between 1 byte and ${MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES} bytes.`);
|
|
77
|
+
if (bytes.byteLength > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - totalBytes)
|
|
78
|
+
throw new TypeError('Manifest artefacts exceed the combined limit.');
|
|
79
|
+
totalBytes += bytes.byteLength;
|
|
80
|
+
const mediaType = enumeration(artifact.mediaType ?? (typeof artifact.content === 'string' ? 'application/json' : 'application/octet-stream'), INVESTIGATION_FILE_MEDIA_TYPES, 'Artefact media type');
|
|
81
|
+
return { bytes, mediaType, source: sourceDeclaration(artifact.source) };
|
|
82
|
+
});
|
|
83
|
+
const artifacts = [];
|
|
84
|
+
for (const [index, file] of selected.entries()) {
|
|
85
|
+
const metadata = file.mediaType === 'application/json'
|
|
86
|
+
? investigationJsonMetadata(new TextDecoder('utf-8', { fatal: true }).decode(file.bytes)) : null;
|
|
87
|
+
artifacts.push(Object.freeze({
|
|
88
|
+
sequence: index + 1, id: `artifact-${index + 1}`,
|
|
89
|
+
schema: metadata?.schema ?? null, version: metadata?.version ?? null,
|
|
90
|
+
byteLength: file.bytes.byteLength,
|
|
91
|
+
contentDigestSha256: await sha256ArtifactBytes(file.bytes),
|
|
92
|
+
canonicalDigestSha256: metadata ? await sha256ArtifactDigestV2(metadata.value) : null,
|
|
93
|
+
mediaType: file.mediaType, source: file.source,
|
|
94
|
+
}));
|
|
95
|
+
}
|
|
96
|
+
const unsigned = Object.freeze({
|
|
97
|
+
schema: INVESTIGATION_MANIFEST_SCHEMA, version: INVESTIGATION_MANIFEST_VERSION,
|
|
98
|
+
generatedAt, application: Object.freeze({ name: 'WHOISleuth', version: applicationVersion }),
|
|
99
|
+
workflow, audience: 'private',
|
|
100
|
+
configuration: Object.freeze({ digestSha256: configurationDigestSha256 }),
|
|
101
|
+
artifacts: Object.freeze(artifacts),
|
|
102
|
+
steps: Object.freeze(artifacts.map((artifact) => Object.freeze({
|
|
103
|
+
sequence: artifact.sequence, artifactId: artifact.id, contentDigestSha256: artifact.contentDigestSha256,
|
|
104
|
+
action: 'packaged', occurredAt: generatedAt,
|
|
105
|
+
}))),
|
|
106
|
+
summary: Object.freeze({ artifactCount: artifacts.length, totalBytes }),
|
|
107
|
+
limitations: Object.freeze([
|
|
108
|
+
'Selected file bytes are unchanged and are not automatically redacted. Review their contents before sharing; original filenames and paths are not retained.',
|
|
109
|
+
'Source identities, observation times and media types are declarations, not independently authenticated evidence. Missing source times remain unknown.',
|
|
110
|
+
'Packaging events use the local clock. They do not establish earlier custody, a trusted timestamp, a signature, source accuracy or current validity.',
|
|
111
|
+
'Digests establish content identity. JSON parsing and declared schema metadata do not establish that a source format is supported or its claims are true.',
|
|
112
|
+
]),
|
|
113
|
+
});
|
|
114
|
+
const manifest = Object.freeze({ ...unsigned, integrity: Object.freeze({
|
|
115
|
+
algorithm: 'SHA-256', canonicalization: SORTED_JSON_V2,
|
|
116
|
+
digestSha256: await sha256ArtifactDigestV2(unsigned),
|
|
117
|
+
}) });
|
|
118
|
+
if (new TextEncoder().encode(canonicalArtifactJsonV2(manifest)).byteLength > MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES)
|
|
119
|
+
throw new TypeError('Investigation manifest metadata exceeds its byte limit.');
|
|
120
|
+
return Object.freeze({ manifest, contents: Object.freeze(selected.map((file) => file.bytes)) });
|
|
121
|
+
}
|
|
122
|
+
export async function buildInvestigationManifest(input, generatedAt, applicationVersion) {
|
|
123
|
+
return (await prepareInvestigationManifest(input, generatedAt, applicationVersion)).manifest;
|
|
124
|
+
}
|
|
125
|
+
export function validateInvestigationManifest(value) {
|
|
126
|
+
const version = integer(value.version, 'Investigation manifest version', 2, INVESTIGATION_MANIFEST_VERSION);
|
|
127
|
+
const current = version === INVESTIGATION_MANIFEST_VERSION;
|
|
128
|
+
const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity', ...(current ? ['audience'] : [])], 'Investigation manifest');
|
|
129
|
+
if (root.schema !== INVESTIGATION_MANIFEST_SCHEMA)
|
|
130
|
+
fail('Investigation manifest schema');
|
|
131
|
+
iso(root.generatedAt, 'Investigation manifest generatedAt');
|
|
132
|
+
const application = exact(root.application, ['name', 'version'], 'Investigation manifest application');
|
|
133
|
+
if (application.name !== (current ? 'WHOISleuth' : 'WHOISleuth CLI'))
|
|
134
|
+
fail('Investigation manifest application');
|
|
135
|
+
normalizeBoundedSemanticVersion(application.version, 'Application');
|
|
136
|
+
text(root.workflow, 'Investigation manifest workflow', 160);
|
|
137
|
+
if (current && root.audience !== 'private')
|
|
138
|
+
fail('Investigation manifest audience');
|
|
139
|
+
const configuration = exact(root.configuration, ['digestSha256'], 'Investigation manifest configuration');
|
|
140
|
+
if (configuration.digestSha256 !== null)
|
|
141
|
+
digest(configuration.digestSha256, 'Investigation manifest configuration');
|
|
142
|
+
const maximumFiles = current ? MAX_INVESTIGATION_MANIFEST_ARTIFACTS : 16;
|
|
143
|
+
const maximumFileBytes = current ? MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES : 16 * 1024 * 1024;
|
|
144
|
+
const maximumTotalBytes = current ? MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES : 32 * 1024 * 1024;
|
|
145
|
+
const artifacts = array(root.artifacts, 'Investigation manifest artifacts', maximumFiles, 1);
|
|
146
|
+
const steps = array(root.steps, 'Investigation manifest steps', maximumFiles, 1);
|
|
147
|
+
if (steps.length !== artifacts.length)
|
|
148
|
+
fail('Investigation manifest step count');
|
|
149
|
+
let totalBytes = 0;
|
|
150
|
+
for (const [index, candidate] of artifacts.entries()) {
|
|
151
|
+
const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256', ...(current ? ['mediaType', 'source'] : [])], `Investigation manifest artifact ${index + 1}`);
|
|
152
|
+
if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1 || item.id !== `artifact-${index + 1}`)
|
|
153
|
+
fail('Investigation manifest artifact order');
|
|
154
|
+
if (item.schema !== null)
|
|
155
|
+
text(item.schema, 'Investigation manifest artifact schema', 160);
|
|
156
|
+
if (item.version !== null)
|
|
157
|
+
integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
|
|
158
|
+
totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, maximumFileBytes);
|
|
159
|
+
digest(item.contentDigestSha256, 'Investigation manifest content digest');
|
|
160
|
+
const mediaType = current ? enumeration(item.mediaType, INVESTIGATION_FILE_MEDIA_TYPES, 'Artefact media type') : 'application/json';
|
|
161
|
+
if (mediaType === 'application/json')
|
|
162
|
+
digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
|
|
163
|
+
else if (item.canonicalDigestSha256 !== null || item.schema !== null || item.version !== null)
|
|
164
|
+
fail('Opaque artefact JSON metadata');
|
|
165
|
+
if (current) {
|
|
166
|
+
const source = exact(item.source, ['identity', 'observedAt'], 'Artefact source');
|
|
167
|
+
if (source.identity !== null)
|
|
168
|
+
text(source.identity, 'Artefact source identity', 240);
|
|
169
|
+
iso(source.observedAt, 'Artefact source observation time', true);
|
|
170
|
+
}
|
|
171
|
+
const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256', ...(current ? ['action', 'occurredAt'] : [])], `Investigation manifest step ${index + 1}`);
|
|
172
|
+
if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
|
|
173
|
+
fail('Investigation manifest step linkage');
|
|
174
|
+
if (current && (step.action !== 'packaged' || step.occurredAt !== root.generatedAt))
|
|
175
|
+
fail('Investigation manifest packaging event');
|
|
176
|
+
}
|
|
177
|
+
const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
|
|
178
|
+
if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, maximumFiles) !== artifacts.length
|
|
179
|
+
|| integer(summary.totalBytes, 'Investigation manifest total bytes', 1, maximumTotalBytes) !== totalBytes)
|
|
180
|
+
fail('Investigation manifest summary');
|
|
181
|
+
strings(root.limitations, 'Investigation manifest limitations', 8, 600);
|
|
182
|
+
validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, version);
|
|
183
|
+
}
|
|
184
|
+
export async function readInvestigationManifest(raw) {
|
|
185
|
+
const value = parseBoundedJsonObject(raw, { label: 'Investigation manifest', maximumBytes: MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES });
|
|
186
|
+
validateInvestigationManifest(value);
|
|
187
|
+
const { integrity, ...unsigned } = value;
|
|
188
|
+
if (await sha256ArtifactDigestV2(unsigned) !== integrity.digestSha256)
|
|
189
|
+
throw new TypeError('Investigation manifest failed its integrity check.');
|
|
190
|
+
return value;
|
|
191
|
+
}
|
|
192
|
+
export function formatInvestigationManifest(manifest) {
|
|
193
|
+
const lines = ['Investigation manifest', `Workflow ${manifest.workflow}`, `Tool version ${manifest.application.version}`,
|
|
194
|
+
`Artifacts ${manifest.summary.artifactCount}`, `Total bytes ${manifest.summary.totalBytes}`,
|
|
195
|
+
`Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`, `Integrity ${manifest.integrity.digestSha256}`, '', 'Ordered artefacts:'];
|
|
196
|
+
for (const artifact of manifest.artifacts)
|
|
197
|
+
lines.push(` ${artifact.sequence}. ${artifact.schema ?? (artifact.canonicalDigestSha256 ? 'unversioned JSON' : 'opaque file')}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
|
|
198
|
+
lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS } from "../contracts/case-portability.mjs";
|
|
2
|
+
import { ENCRYPTED_INVESTIGATION_PACKAGE_MAGIC, ENCRYPTED_INVESTIGATION_PACKAGE_VERSION, ENCRYPTED_INVESTIGATION_PACKAGE_HEADER_BYTES, MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES, MAX_INVESTIGATION_PACKAGE_BYTES, hasEncryptedInvestigationPackagePrefix, } from "../contracts/investigation-package-limits.mjs";
|
|
3
|
+
import { arrayBuffer, assertPassphrase, cryptoProvider, deriveArchiveKey } from "../evidence/passphrase-encryption.mjs";
|
|
4
|
+
import { inspectInvestigationPackage } from "./investigation-package.mjs";
|
|
5
|
+
const VERSION_OFFSET = ENCRYPTED_INVESTIGATION_PACKAGE_MAGIC.length;
|
|
6
|
+
const ITERATIONS_OFFSET = VERSION_OFFSET + 1;
|
|
7
|
+
const SALT_OFFSET = ITERATIONS_OFFSET + 4;
|
|
8
|
+
const IV_OFFSET = SALT_OFFSET + 16;
|
|
9
|
+
const LENGTH_OFFSET = IV_OFFSET + 12;
|
|
10
|
+
const HEADER_BYTES = ENCRYPTED_INVESTIGATION_PACKAGE_HEADER_BYTES;
|
|
11
|
+
const TAG_BYTES = 16;
|
|
12
|
+
function boundedSnapshot(input, maximum) {
|
|
13
|
+
if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer)
|
|
14
|
+
|| input.byteLength < 22 || input.byteLength > maximum)
|
|
15
|
+
throw new TypeError('Evidence package exceeds its input byte limit or uses shared memory.');
|
|
16
|
+
return new Uint8Array(input);
|
|
17
|
+
}
|
|
18
|
+
/** Fixed-width binary metadata. No targets, file identities or source claims are exposed. */
|
|
19
|
+
export function inspectEncryptedInvestigationPackage(input) {
|
|
20
|
+
if (!(input instanceof Uint8Array) || !(input.buffer instanceof ArrayBuffer) || !hasEncryptedInvestigationPackagePrefix(input)
|
|
21
|
+
|| input.byteLength < HEADER_BYTES + TAG_BYTES + 22 || input.byteLength > MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES) {
|
|
22
|
+
throw new TypeError('The encrypted evidence package is malformed or exceeds its byte limit.');
|
|
23
|
+
}
|
|
24
|
+
if (input[VERSION_OFFSET] !== ENCRYPTED_INVESTIGATION_PACKAGE_VERSION)
|
|
25
|
+
throw new TypeError('Unsupported encrypted evidence package version. No file was decrypted.');
|
|
26
|
+
const header = input.slice(0, HEADER_BYTES);
|
|
27
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
28
|
+
if (view.getUint32(ITERATIONS_OFFSET) !== WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS)
|
|
29
|
+
throw new TypeError('Unsupported encrypted evidence package key-derivation parameters.');
|
|
30
|
+
const plaintextBytes = view.getUint32(LENGTH_OFFSET);
|
|
31
|
+
if (plaintextBytes < 22 || plaintextBytes > MAX_INVESTIGATION_PACKAGE_BYTES
|
|
32
|
+
|| input.byteLength !== HEADER_BYTES + plaintextBytes + TAG_BYTES)
|
|
33
|
+
throw new TypeError('Encrypted evidence package length does not match its declaration.');
|
|
34
|
+
return Object.freeze({ version: ENCRYPTED_INVESTIGATION_PACKAGE_VERSION, plaintextBytes, ciphertextBytes: plaintextBytes + TAG_BYTES });
|
|
35
|
+
}
|
|
36
|
+
/** Encrypt an independently verified package, never a partly accepted archive. */
|
|
37
|
+
export async function encryptInvestigationPackage(input, passphrase, provider) {
|
|
38
|
+
const plaintext = boundedSnapshot(input, MAX_INVESTIGATION_PACKAGE_BYTES);
|
|
39
|
+
let secret = null;
|
|
40
|
+
try {
|
|
41
|
+
secret = assertPassphrase(passphrase);
|
|
42
|
+
const inspected = await inspectInvestigationPackage(plaintext);
|
|
43
|
+
if (!inspected.identityVerified)
|
|
44
|
+
throw new TypeError('The evidence package contains an unverified file. Nothing was encrypted.');
|
|
45
|
+
const crypto = cryptoProvider(provider);
|
|
46
|
+
const salt = crypto.getRandomValues(new Uint8Array(16)), iv = crypto.getRandomValues(new Uint8Array(12));
|
|
47
|
+
const header = new Uint8Array(HEADER_BYTES), view = new DataView(header.buffer);
|
|
48
|
+
header.set(new TextEncoder().encode(ENCRYPTED_INVESTIGATION_PACKAGE_MAGIC));
|
|
49
|
+
header[VERSION_OFFSET] = ENCRYPTED_INVESTIGATION_PACKAGE_VERSION;
|
|
50
|
+
view.setUint32(ITERATIONS_OFFSET, WORKSPACE_ARCHIVE_PBKDF2_ITERATIONS);
|
|
51
|
+
header.set(salt, SALT_OFFSET);
|
|
52
|
+
header.set(iv, IV_OFFSET);
|
|
53
|
+
view.setUint32(LENGTH_OFFSET, plaintext.byteLength);
|
|
54
|
+
const key = await deriveArchiveKey(crypto, secret, salt, ['encrypt']);
|
|
55
|
+
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: arrayBuffer(iv), additionalData: header, tagLength: TAG_BYTES * 8 }, key, plaintext);
|
|
56
|
+
const output = new Uint8Array(HEADER_BYTES + encrypted.byteLength);
|
|
57
|
+
output.set(header);
|
|
58
|
+
output.set(new Uint8Array(encrypted), HEADER_BYTES);
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
secret?.fill(0);
|
|
63
|
+
plaintext.fill(0);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Authenticate all container bytes before passing the plaintext to the ZIP reader. */
|
|
67
|
+
export async function decryptInvestigationPackage(input, passphrase, provider) {
|
|
68
|
+
const snapshot = boundedSnapshot(input, MAX_ENCRYPTED_INVESTIGATION_PACKAGE_BYTES);
|
|
69
|
+
let secret = null;
|
|
70
|
+
let plaintext = null;
|
|
71
|
+
try {
|
|
72
|
+
const metadata = inspectEncryptedInvestigationPackage(snapshot);
|
|
73
|
+
secret = assertPassphrase(passphrase);
|
|
74
|
+
const crypto = cryptoProvider(provider);
|
|
75
|
+
try {
|
|
76
|
+
const key = await deriveArchiveKey(crypto, secret, snapshot.slice(SALT_OFFSET, IV_OFFSET), ['decrypt']);
|
|
77
|
+
plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: snapshot.slice(IV_OFFSET, LENGTH_OFFSET),
|
|
78
|
+
additionalData: snapshot.slice(0, HEADER_BYTES), tagLength: TAG_BYTES * 8 }, key, snapshot.slice(HEADER_BYTES)));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
throw new Error('The package passphrase is incorrect or the encrypted file is corrupted.');
|
|
82
|
+
}
|
|
83
|
+
if (plaintext.byteLength !== metadata.plaintextBytes)
|
|
84
|
+
throw new TypeError('Decrypted evidence package length is invalid.');
|
|
85
|
+
const review = await inspectInvestigationPackage(plaintext);
|
|
86
|
+
if (!review.identityVerified)
|
|
87
|
+
throw new TypeError('The decrypted package contains an unverified file. No evidence was accepted.');
|
|
88
|
+
return { bytes: plaintext, review, ciphertextBytes: metadata.ciphertextBytes };
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
plaintext?.fill(0);
|
|
92
|
+
throw cause;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
secret?.fill(0);
|
|
96
|
+
snapshot.fill(0);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { zipSync } from 'fflate';
|
|
2
|
+
import { MAX_INVESTIGATION_PACKAGE_ENTRIES, MAX_INVESTIGATION_PACKAGE_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
3
|
+
export { MAX_INVESTIGATION_PACKAGE_ENTRIES, MAX_INVESTIGATION_PACKAGE_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
4
|
+
import { extractBoundedZipEntries } from "../interchange/bounded-zip-extraction.mjs";
|
|
5
|
+
import { canonicalArtifactJsonV2, sha256ArtifactBytes, sha256ArtifactDigestV2 } from "../evidence/artifact-integrity.mjs";
|
|
6
|
+
import { INVESTIGATION_CAPSULE_SCHEMA, investigationCapsuleSourceIdentity } from "./investigation-capsule.mjs";
|
|
7
|
+
import { matchCaptureArtifacts, readWebCaptureManifest } from "../interchange/web-capture-import.mjs";
|
|
8
|
+
import { MAX_WEB_CAPTURE_MANIFEST_BYTES, WEB_CAPTURE_MANIFEST_SCHEMA } from "../contracts/web-capture.mjs";
|
|
9
|
+
import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, investigationJsonMetadata, prepareInvestigationManifest, readInvestigationManifest, } from "./investigation-manifest.mjs";
|
|
10
|
+
export const INVESTIGATION_PACKAGE_MANIFEST_PATH = 'manifest.json';
|
|
11
|
+
const MANIFEST_PATH = INVESTIGATION_PACKAGE_MANIFEST_PATH;
|
|
12
|
+
const MAX_PACKAGE_ENTRIES = MAX_INVESTIGATION_PACKAGE_ENTRIES;
|
|
13
|
+
export function investigationPackageEntryPath(id) {
|
|
14
|
+
const match = /^artifact-([1-9]\d{0,2})$/u.exec(id);
|
|
15
|
+
if (!match || Number(match[1]) > MAX_INVESTIGATION_MANIFEST_ARTIFACTS)
|
|
16
|
+
throw new TypeError('Invalid investigation package entry identity.');
|
|
17
|
+
return `artifacts/${id}`;
|
|
18
|
+
}
|
|
19
|
+
export function investigationPackagePath(name) {
|
|
20
|
+
if (typeof name !== 'string')
|
|
21
|
+
throw new TypeError('Investigation package entry paths must be strings.');
|
|
22
|
+
if (name === MANIFEST_PATH)
|
|
23
|
+
return name;
|
|
24
|
+
if (!name.startsWith('artifacts/') || investigationPackageEntryPath(name.slice('artifacts/'.length)) !== name)
|
|
25
|
+
throw new TypeError('Investigation package contains an unexpected entry path.');
|
|
26
|
+
return name;
|
|
27
|
+
}
|
|
28
|
+
export async function prepareInvestigationPackageEntries(input, generatedAt, applicationVersion) {
|
|
29
|
+
const prepared = await prepareInvestigationManifest(input, generatedAt, applicationVersion);
|
|
30
|
+
const files = new Map();
|
|
31
|
+
files.set(MANIFEST_PATH, new TextEncoder().encode(canonicalArtifactJsonV2(prepared.manifest)));
|
|
32
|
+
for (const [index, item] of prepared.manifest.artifacts.entries())
|
|
33
|
+
files.set(investigationPackageEntryPath(item.id), prepared.contents[index]);
|
|
34
|
+
return Object.freeze({ manifest: prepared.manifest, files });
|
|
35
|
+
}
|
|
36
|
+
/** Capture a directory selection before hashing; no caller-owned bytes survive. */
|
|
37
|
+
function capturePackageEntries(input) {
|
|
38
|
+
if (!(input instanceof Map) || input.size < 2 || input.size > MAX_PACKAGE_ENTRIES)
|
|
39
|
+
throw new TypeError('Investigation package has an invalid entry count.');
|
|
40
|
+
const files = new Map();
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const [name, bytes] of input) {
|
|
43
|
+
investigationPackagePath(name);
|
|
44
|
+
const maximum = name === MANIFEST_PATH ? MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES : MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES;
|
|
45
|
+
if (!(bytes instanceof Uint8Array) || bytes.byteLength < 1 || bytes.byteLength > maximum)
|
|
46
|
+
throw new TypeError('Investigation package entry exceeds its byte limit.');
|
|
47
|
+
if (name !== MANIFEST_PATH) {
|
|
48
|
+
if (bytes.byteLength > MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES - total)
|
|
49
|
+
throw new TypeError('Investigation package exceeds its combined byte limit.');
|
|
50
|
+
total += bytes.byteLength;
|
|
51
|
+
}
|
|
52
|
+
files.set(name, new Uint8Array(bytes));
|
|
53
|
+
}
|
|
54
|
+
if (!files.has(MANIFEST_PATH))
|
|
55
|
+
throw new TypeError('Investigation package is missing its manifest.');
|
|
56
|
+
return files;
|
|
57
|
+
}
|
|
58
|
+
/** A stable container representation, independent of directory listing order. */
|
|
59
|
+
export function encodeInvestigationPackageEntries(input) {
|
|
60
|
+
const captured = capturePackageEntries(input);
|
|
61
|
+
const files = Object.create(null);
|
|
62
|
+
for (const name of [...captured.keys()].sort())
|
|
63
|
+
files[name] = captured.get(name);
|
|
64
|
+
// Stored entries avoid re-compressing images and large JSON while retaining
|
|
65
|
+
// exact source bytes. The reader also admits independently bounded deflate.
|
|
66
|
+
const bytes = zipSync(files, { level: 0, mtime: new Date(1980, 0, 1) });
|
|
67
|
+
if (bytes.byteLength > MAX_INVESTIGATION_PACKAGE_BYTES)
|
|
68
|
+
throw new TypeError('Investigation package exceeds its byte limit.');
|
|
69
|
+
return bytes;
|
|
70
|
+
}
|
|
71
|
+
export async function buildInvestigationPackage(input, generatedAt, applicationVersion) {
|
|
72
|
+
const prepared = await prepareInvestigationPackageEntries(input, generatedAt, applicationVersion);
|
|
73
|
+
return Object.freeze({ manifest: prepared.manifest, bytes: encodeInvestigationPackageEntries(prepared.files) });
|
|
74
|
+
}
|
|
75
|
+
export async function inspectInvestigationPackage(input) {
|
|
76
|
+
if (!(input instanceof Uint8Array) || input.byteLength < 22 || input.byteLength > MAX_INVESTIGATION_PACKAGE_BYTES)
|
|
77
|
+
throw new TypeError(`Investigation package input must fit within ${MAX_INVESTIGATION_PACKAGE_BYTES} bytes.`);
|
|
78
|
+
// The bounded reader verifies local/central identity and every selected CRC.
|
|
79
|
+
// All entries are selected: an unverified trailing file cannot hide in a pack.
|
|
80
|
+
const extracted = extractBoundedZipEntries(input, {
|
|
81
|
+
maximumEntries: MAX_PACKAGE_ENTRIES,
|
|
82
|
+
maximumSelectedBytes: MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES + MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES,
|
|
83
|
+
selectedBytesExceededMessage: 'Investigation package exceeds its inflated byte limit.',
|
|
84
|
+
metadataMismatchMessage: 'Investigation package ZIP metadata or CRC is invalid.',
|
|
85
|
+
keyForName: investigationPackagePath,
|
|
86
|
+
inspect(info) {
|
|
87
|
+
const key = investigationPackagePath(info.name);
|
|
88
|
+
const maximumBytes = key === MANIFEST_PATH ? MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES : MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES;
|
|
89
|
+
if (info.originalSize < 1 || info.originalSize > maximumBytes)
|
|
90
|
+
throw new TypeError('Investigation package entry exceeds its byte limit.');
|
|
91
|
+
return { key, selected: true, maximumBytes, exceededMessage: 'Investigation package entry exceeds its inflated byte limit.' };
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return inspectOwnedPackageEntries(extracted.files);
|
|
95
|
+
}
|
|
96
|
+
export async function inspectInvestigationPackageEntries(input) {
|
|
97
|
+
return inspectOwnedPackageEntries(capturePackageEntries(input));
|
|
98
|
+
}
|
|
99
|
+
async function inspectOwnedPackageEntries(files) {
|
|
100
|
+
const manifestBytes = files.get(MANIFEST_PATH);
|
|
101
|
+
if (!manifestBytes)
|
|
102
|
+
throw new TypeError('Investigation package is missing its manifest.');
|
|
103
|
+
const manifest = await readInvestigationManifest(new TextDecoder('utf-8', { fatal: true }).decode(manifestBytes));
|
|
104
|
+
if (files.size !== manifest.artifacts.length + 1)
|
|
105
|
+
throw new TypeError('Investigation package has missing or unlisted files.');
|
|
106
|
+
const entries = [];
|
|
107
|
+
const contents = new Map();
|
|
108
|
+
const captureDeclarations = new Map();
|
|
109
|
+
const jsonSources = new Map();
|
|
110
|
+
for (const entry of manifest.artifacts) {
|
|
111
|
+
const content = files.get(investigationPackageEntryPath(entry.id));
|
|
112
|
+
if (!content)
|
|
113
|
+
throw new TypeError('Investigation package is missing a declared file.');
|
|
114
|
+
const isJson = !('mediaType' in entry) || entry.mediaType === 'application/json';
|
|
115
|
+
const byteLength = content.byteLength === entry.byteLength;
|
|
116
|
+
const rawDigest = await sha256ArtifactBytes(content) === entry.contentDigestSha256;
|
|
117
|
+
let canonicalDigest = isJson ? false : null;
|
|
118
|
+
let schema = isJson ? false : null;
|
|
119
|
+
let version = isJson ? false : null;
|
|
120
|
+
let issue = null;
|
|
121
|
+
let interpretation = isJson ? 'not_checked' : 'opaque';
|
|
122
|
+
if (isJson) {
|
|
123
|
+
try {
|
|
124
|
+
const metadata = investigationJsonMetadata(new TextDecoder('utf-8', { fatal: true }).decode(content));
|
|
125
|
+
canonicalDigest = await sha256ArtifactDigestV2(metadata.value) === entry.canonicalDigestSha256;
|
|
126
|
+
schema = metadata.schema === entry.schema;
|
|
127
|
+
version = metadata.version === entry.version;
|
|
128
|
+
if (!metadata.object)
|
|
129
|
+
interpretation = 'unsupported_json_value';
|
|
130
|
+
if (metadata.object && byteLength && rawDigest && canonicalDigest && schema && version) {
|
|
131
|
+
if (metadata.schema === WEB_CAPTURE_MANIFEST_SCHEMA) {
|
|
132
|
+
try {
|
|
133
|
+
if (content.byteLength > MAX_WEB_CAPTURE_MANIFEST_BYTES)
|
|
134
|
+
throw new TypeError('Capture manifest is oversized.');
|
|
135
|
+
captureDeclarations.set(entry.id, readWebCaptureManifest(metadata.object).artifacts);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
captureDeclarations.set(entry.id, null);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const query = metadata.object.query;
|
|
142
|
+
const queryObject = query && typeof query === 'object' && !Array.isArray(query) ? query : null;
|
|
143
|
+
jsonSources.set(entry.id, {
|
|
144
|
+
schema: metadata.schema, version: metadata.version, digest: entry.canonicalDigestSha256,
|
|
145
|
+
query: queryObject ? { submitted: queryObject.submitted, type: queryObject.type } : null,
|
|
146
|
+
target: metadata.schema === INVESTIGATION_CAPSULE_SCHEMA ? metadata.object.target : null,
|
|
147
|
+
references: metadata.schema === INVESTIGATION_CAPSULE_SCHEMA ? metadata.object.sourceContracts : null,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
issue = 'The declared JSON file is not valid bounded UTF-8 JSON.';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const verified = byteLength && rawDigest && canonicalDigest !== false && schema !== false && version !== false;
|
|
156
|
+
entries.push(Object.freeze({ entry, state: verified ? 'identity_verified' : 'rejected',
|
|
157
|
+
checks: Object.freeze({ byteLength, rawDigest, canonicalDigest, schema, version }),
|
|
158
|
+
interpretation,
|
|
159
|
+
issue: issue ?? (verified ? null : 'File content does not match its manifest identity.'),
|
|
160
|
+
}));
|
|
161
|
+
// Rejected bytes remain in the selected archive but cannot be used by an
|
|
162
|
+
// importing caller as if they had passed identity verification.
|
|
163
|
+
if (verified)
|
|
164
|
+
contents.set(entry.id, content);
|
|
165
|
+
}
|
|
166
|
+
const links = [];
|
|
167
|
+
const captureManifests = [...captureDeclarations].map(([entryId, declarations]) => {
|
|
168
|
+
if (!declarations)
|
|
169
|
+
return { entryId, state: 'rejected', artifacts: [] };
|
|
170
|
+
const artifacts = matchCaptureArtifacts(declarations, entries
|
|
171
|
+
.filter(candidate => candidate.entry.id !== entryId && candidate.state === 'identity_verified')
|
|
172
|
+
.map(candidate => ({ id: candidate.entry.id, bytes: candidate.entry.byteLength, sha256: candidate.entry.contentDigestSha256 })));
|
|
173
|
+
return { entryId, state: artifacts.every(artifact => artifact.state === 'matched') ? 'matched' : 'missing_attachments', artifacts };
|
|
174
|
+
});
|
|
175
|
+
for (const [id, capsule] of jsonSources) {
|
|
176
|
+
if (capsule.schema !== INVESTIGATION_CAPSULE_SCHEMA)
|
|
177
|
+
continue;
|
|
178
|
+
const references = Array.isArray(capsule.references) && capsule.references.length <= 4
|
|
179
|
+
? capsule.references.filter((item) => item && typeof item === 'object' && item.id === 'lookup-evidence') : [];
|
|
180
|
+
if (references.length !== 1) {
|
|
181
|
+
links.push({ capsuleEntryId: id, sourceEntryId: null, state: 'mismatch' });
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const reference = references[0];
|
|
185
|
+
const candidates = [...jsonSources].filter(([otherId, source]) => otherId !== id && source.schema === reference.schema && source.digest === reference.digest);
|
|
186
|
+
if (candidates.length !== 1) {
|
|
187
|
+
const state = candidates.length > 1 ? 'ambiguous' : [...jsonSources.values()].some((source) => source.schema === reference.schema) ? 'mismatch' : 'missing';
|
|
188
|
+
links.push({ capsuleEntryId: id, sourceEntryId: null, state });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const [sourceEntryId, source] = candidates[0];
|
|
192
|
+
const identity = investigationCapsuleSourceIdentity(capsule.target, reference, source);
|
|
193
|
+
links.push({ capsuleEntryId: id, sourceEntryId, state: identity.linked ? 'linked' : 'mismatch' });
|
|
194
|
+
}
|
|
195
|
+
return Object.freeze({ manifest, entries: Object.freeze(entries), contents, links: Object.freeze(links), captureManifests: Object.freeze(captureManifests),
|
|
196
|
+
identityVerified: entries.every((entry) => entry.state === 'identity_verified'),
|
|
197
|
+
storageEffect: 'none',
|
|
198
|
+
signatureTrust: 'not_checked', timestampAssurance: 'not_checked', factualAccuracy: 'not_established',
|
|
199
|
+
});
|
|
200
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Counts describe the projection input, not the collector's upstream universe. */
|
|
2
|
+
export function createGraphInputReader() {
|
|
3
|
+
const coverage = new Map();
|
|
4
|
+
function values(id, value, maximum, normalise, identity) {
|
|
5
|
+
const input = Array.isArray(value) ? value : [];
|
|
6
|
+
const inspected = Math.min(input.length, maximum * 4);
|
|
7
|
+
const result = [];
|
|
8
|
+
const seen = new Set();
|
|
9
|
+
let invalid = 0;
|
|
10
|
+
let duplicates = 0;
|
|
11
|
+
let omitted = 0;
|
|
12
|
+
for (let index = 0; index < inspected; index += 1) {
|
|
13
|
+
const item = normalise(input[index]);
|
|
14
|
+
if (item === null) {
|
|
15
|
+
invalid += 1;
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const key = identity?.(item);
|
|
19
|
+
if (key !== undefined && seen.has(key)) {
|
|
20
|
+
duplicates += 1;
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (key !== undefined)
|
|
24
|
+
seen.add(key);
|
|
25
|
+
if (result.length === maximum) {
|
|
26
|
+
omitted += 1;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
result.push(item);
|
|
30
|
+
}
|
|
31
|
+
coverage.set(id, { id, supplied: input.length, inspected, admitted: result.length, invalid, duplicates, omitted });
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
return { values, coverage };
|
|
35
|
+
}
|
|
36
|
+
export function graphInputIsIncomplete(row) {
|
|
37
|
+
return row.supplied > row.inspected || row.invalid > 0 || row.omitted > 0;
|
|
38
|
+
}
|