@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,14 +1,18 @@
|
|
|
1
1
|
import { ACQUISITION_DECISIONS, ACQUISITION_MANUAL_CHECKS, ACQUISITION_DECISION_PACKET_SCHEMA, ACQUISITION_DECISION_PACKET_VERSION, } from "../../packages/investigation/acquisition-decision-packet.mjs";
|
|
2
2
|
import { BULK_DOMAIN_COMPARISON_EXPORT_VERSION, BULK_DOMAIN_COMPARISON_SCHEMA, } from "../../packages/investigation/bulk-domain-comparison.mjs";
|
|
3
3
|
import { BULK_MAIL_EXPOSURE_EXPORT_VERSION, BULK_MAIL_EXPOSURE_SCHEMA, MAX_BULK_MAIL_EXPOSURE_ROWS, } from "../../packages/investigation/bulk-mail-exposure.mjs";
|
|
4
|
-
import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, } from "../../packages/investigation
|
|
4
|
+
import { BULK_REVIEW_MANIFEST_SCHEMA, BULK_REVIEW_MANIFEST_VERSION, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../../packages/contracts/investigation-portability.mjs";
|
|
5
|
+
import { MAX_BULK_SESSION_ROWS, MAX_BULK_SESSION_SOURCES } from "../../packages/contracts/workspace-portability.mjs";
|
|
6
|
+
import { BULK_SORT_KEYS } from "../../packages/workspace/bulk-sort.mjs";
|
|
7
|
+
import { normalizeExplicitIsoTimestamp } from "../../packages/evidence/observation.mjs";
|
|
5
8
|
import { LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_TARGET_TYPES, LOOKUP_CLAIM_PASSPORT_VERSION, MAX_LOOKUP_CLAIM_PASSPORT_LIMITATIONS, MAX_LOOKUP_CLAIM_PASSPORT_REQUIREMENTS, } from "../../packages/investigation/lookup-claim-passport.mjs";
|
|
6
9
|
import { LOOKUP_CLAIM_IDS, LOOKUP_CLAIM_READINESS_VERSION, LOOKUP_CLAIM_REQUIREMENT_IDS, } from "../../packages/investigation/lookup-claim-readiness.mjs";
|
|
7
10
|
import { normalizeDomainControlManifestDocument } from "../../packages/evidence/domain-control-runtime.mjs";
|
|
8
|
-
import { DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
11
|
+
import { DOMAIN_CHANGE_PACKET_SCHEMA, DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS, } from "../../lib/domain-change-packet.mjs";
|
|
12
|
+
import { DOMAIN_CHANGE_REVIEW_SCHEMA } from "../../lib/domain-change-review.mjs";
|
|
9
13
|
import { DOMAIN_CONTROL_MANIFEST_SCHEMA } from "../../packages/contracts/domain-control-manifest.mjs";
|
|
10
|
-
import { INVESTIGATION_MANIFEST_SCHEMA,
|
|
11
|
-
import { array, boolean, domain,
|
|
14
|
+
import { INVESTIGATION_MANIFEST_SCHEMA, validateInvestigationManifest, } from "../investigation-manifest.mjs";
|
|
15
|
+
import { array, boolean, domain, enumeration, exact, fail, integer, iso, optionalText, sameValues, strings, text, validateIntegrity, } from "./structure-primitives.mjs";
|
|
12
16
|
const CLAIM_PASSPORT_SOURCE_STATES = ['complete', 'not_found', 'partial', 'skipped', 'unavailable', 'unknown', 'unsupported'];
|
|
13
17
|
const COMPARISON_STATES = ['conflicting', 'different', 'equal', 'missing', 'not_recorded', 'unavailable'];
|
|
14
18
|
const SOURCE_STATES = ['complete', 'error', 'not_found', 'not_recorded', 'partial', 'skipped', 'unavailable', 'unsupported'];
|
|
@@ -209,11 +213,17 @@ function validateDomainComparison(value) {
|
|
|
209
213
|
strings(comparison.limitations, 'Domain comparison limitations', 12, 600);
|
|
210
214
|
validateIntegrity(root.integrity, 'Domain comparison integrity', root.version, BULK_DOMAIN_COMPARISON_EXPORT_VERSION);
|
|
211
215
|
}
|
|
212
|
-
function
|
|
213
|
-
|
|
216
|
+
function validateNullableObservationTime(value, label) {
|
|
217
|
+
if (value !== null && normalizeExplicitIsoTimestamp(value) !== value)
|
|
218
|
+
fail(label);
|
|
219
|
+
}
|
|
220
|
+
function validateSourceCoverage(value, label, observedTime = false) {
|
|
221
|
+
const source = exact(value, ['source', 'state', ...(observedTime ? ['observedAt'] : [])], label);
|
|
214
222
|
if (typeof source.source !== 'string' || !/^[a-z][a-z0-9_-]{0,39}$/u.test(source.source))
|
|
215
223
|
fail(label);
|
|
216
224
|
enumeration(source.state, SOURCE_STATES.filter((state) => state !== 'not_recorded'), label);
|
|
225
|
+
if (observedTime)
|
|
226
|
+
validateNullableObservationTime(source.observedAt, `${label} observedAt`);
|
|
217
227
|
}
|
|
218
228
|
function validateProfileContext(value, label) {
|
|
219
229
|
const context = exact(value, ['sourceState', 'activeProfileId', 'profileUpdatedAt', 'limitation'], label);
|
|
@@ -281,29 +291,40 @@ function validateBulkView(value, label) {
|
|
|
281
291
|
text(view.caseDispositionFilter, label, 60, true);
|
|
282
292
|
enumeration(view.reviewStateFilter, ['', 'unreviewed', 'reviewing', 'reviewed', 'deferred'], label);
|
|
283
293
|
text(view.groupBy, label, 60, true);
|
|
284
|
-
enumeration(view.sortKey,
|
|
294
|
+
enumeration(view.sortKey, BULK_SORT_KEYS, label);
|
|
285
295
|
if (view.sortDirection !== 1 && view.sortDirection !== -1)
|
|
286
296
|
fail(label);
|
|
287
297
|
}
|
|
288
298
|
function validateBulkReviewManifest(value) {
|
|
289
299
|
const root = exact(value, ['schema', 'version', 'generatedAt', 'observedAt', 'lookupProfile', 'selection', 'view', 'rows', 'limitations', 'integrity'], 'Bulk review manifest');
|
|
300
|
+
const version = SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS.find((candidate) => candidate === root.version);
|
|
301
|
+
if (version === undefined)
|
|
302
|
+
fail('Bulk review manifest version');
|
|
303
|
+
const sourceTimes = version === BULK_REVIEW_MANIFEST_VERSION;
|
|
304
|
+
const maximumRows = sourceTimes ? MAX_BULK_SESSION_ROWS : 2_000;
|
|
305
|
+
const maximumSources = sourceTimes ? MAX_BULK_SESSION_SOURCES : 12;
|
|
290
306
|
iso(root.generatedAt, 'Bulk review manifest generatedAt');
|
|
291
|
-
|
|
307
|
+
if (sourceTimes)
|
|
308
|
+
validateNullableObservationTime(root.observedAt, 'Bulk review manifest observedAt');
|
|
309
|
+
else
|
|
310
|
+
iso(root.observedAt, 'Bulk review manifest observedAt');
|
|
292
311
|
enumeration(root.lookupProfile, ['deep', 'fast'], 'Bulk review manifest lookup profile');
|
|
293
312
|
const selection = exact(root.selection, ['count', 'domains'], 'Bulk review manifest selection');
|
|
294
|
-
const domains = strings(selection.domains, 'Bulk review manifest selected domains',
|
|
313
|
+
const domains = strings(selection.domains, 'Bulk review manifest selected domains', maximumRows, 253);
|
|
295
314
|
domains.forEach((item) => domain(item, 'Bulk review manifest selected domain'));
|
|
296
|
-
if (integer(selection.count, 'Bulk review manifest selection count', 0,
|
|
315
|
+
if (integer(selection.count, 'Bulk review manifest selection count', 0, maximumRows) !== domains.length)
|
|
297
316
|
fail('Bulk review manifest selection');
|
|
298
317
|
validateBulkView(root.view, 'Bulk review manifest view');
|
|
299
|
-
const rows = array(root.rows, 'Bulk review manifest rows',
|
|
318
|
+
const rows = array(root.rows, 'Bulk review manifest rows', maximumRows);
|
|
300
319
|
for (const [index, candidate] of rows.entries()) {
|
|
301
|
-
const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
|
|
320
|
+
const row = exact(candidate, ['domain', 'reviewState', 'resultState', 'scanDepth', ...(sourceTimes ? ['observedAt'] : []), 'sourceCoverage', 'profileContext'], `Bulk review manifest row ${index + 1}`);
|
|
302
321
|
domain(row.domain, 'Bulk review manifest row domain');
|
|
303
322
|
enumeration(row.reviewState, ['unreviewed', 'reviewing', 'reviewed', 'deferred'], 'Bulk review manifest review state');
|
|
304
323
|
enumeration(row.resultState, ['complete', 'error'], 'Bulk review manifest result state');
|
|
305
324
|
enumeration(row.scanDepth, ['deep', 'fast'], 'Bulk review manifest scan depth');
|
|
306
|
-
|
|
325
|
+
if (sourceTimes)
|
|
326
|
+
validateNullableObservationTime(row.observedAt, 'Bulk review manifest row observedAt');
|
|
327
|
+
array(row.sourceCoverage, 'Bulk review manifest source coverage', maximumSources).forEach((item, sourceIndex) => validateSourceCoverage(item, `Bulk review manifest source ${sourceIndex + 1}`, sourceTimes));
|
|
307
328
|
validateProfileContext(row.profileContext, 'Bulk review manifest profile context');
|
|
308
329
|
if (domains[index] !== row.domain)
|
|
309
330
|
fail('Bulk review manifest selection');
|
|
@@ -311,59 +332,49 @@ function validateBulkReviewManifest(value) {
|
|
|
311
332
|
if (rows.length !== domains.length)
|
|
312
333
|
fail('Bulk review manifest selection');
|
|
313
334
|
strings(root.limitations, 'Bulk review manifest limitations', 8, 600);
|
|
314
|
-
validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version,
|
|
335
|
+
validateIntegrity(root.integrity, 'Bulk review manifest integrity', root.version, version);
|
|
315
336
|
}
|
|
316
|
-
function
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
let totalBytes = 0;
|
|
330
|
-
for (const [index, candidate] of artifacts.entries()) {
|
|
331
|
-
const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256'], `Investigation manifest artifact ${index + 1}`);
|
|
332
|
-
if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1
|
|
333
|
-
|| item.id !== `artifact-${index + 1}`)
|
|
334
|
-
fail('Investigation manifest artifact order');
|
|
335
|
-
if (item.schema !== null)
|
|
336
|
-
text(item.schema, 'Investigation manifest artifact schema', 160);
|
|
337
|
-
if (item.version !== null)
|
|
338
|
-
integer(item.version, 'Investigation manifest artifact version', 1, 1_000);
|
|
339
|
-
totalBytes += integer(item.byteLength, 'Investigation manifest artifact bytes', 1, 15 * 1024 * 1024);
|
|
340
|
-
digest(item.contentDigestSha256, 'Investigation manifest content digest');
|
|
341
|
-
digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
|
|
342
|
-
const step = exact(steps[index], ['sequence', 'artifactId', 'contentDigestSha256'], `Investigation manifest step ${index + 1}`);
|
|
343
|
-
if (step.sequence !== item.sequence || step.artifactId !== item.id || step.contentDigestSha256 !== item.contentDigestSha256)
|
|
344
|
-
fail('Investigation manifest step linkage');
|
|
345
|
-
}
|
|
346
|
-
const summary = exact(root.summary, ['artifactCount', 'totalBytes'], 'Investigation manifest summary');
|
|
347
|
-
if (integer(summary.artifactCount, 'Investigation manifest artifact count', 1, MAX_INVESTIGATION_MANIFEST_ARTIFACTS) !== artifacts.length
|
|
348
|
-
|| integer(summary.totalBytes, 'Investigation manifest total bytes', 1, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES) !== totalBytes)
|
|
349
|
-
fail('Investigation manifest summary');
|
|
350
|
-
strings(root.limitations, 'Investigation manifest limitations', 8, 600);
|
|
351
|
-
validateIntegrity(root.integrity, 'Investigation manifest integrity', root.version, INVESTIGATION_MANIFEST_VERSION);
|
|
337
|
+
function validateSourceObservations(value, label) {
|
|
338
|
+
const names = new Set();
|
|
339
|
+
return array(value, label, 16).map((candidate) => {
|
|
340
|
+
const source = exact(candidate, ['label', 'source', 'state', 'observedAt'], label);
|
|
341
|
+
const name = text(source.label, `${label} label`, 120).toLowerCase();
|
|
342
|
+
if (names.has(name))
|
|
343
|
+
fail(`${label} duplicate label`);
|
|
344
|
+
names.add(name);
|
|
345
|
+
text(source.source, `${label} source`, 240);
|
|
346
|
+
enumeration(source.state, ['observed', 'partial', 'unavailable'], `${label} state`);
|
|
347
|
+
iso(source.observedAt, `${label} observedAt`);
|
|
348
|
+
return source;
|
|
349
|
+
});
|
|
352
350
|
}
|
|
353
|
-
function validateReviewMatrix(value, label) {
|
|
351
|
+
function validateReviewMatrix(value, label, sources) {
|
|
354
352
|
for (const [rowIndex, candidate] of array(value, label, 500).entries()) {
|
|
355
353
|
const row = exact(candidate, ['owner', 'type', 'state', 'observations'], `${label} row ${rowIndex + 1}`);
|
|
356
354
|
text(row.owner, `${label} owner`, 253);
|
|
357
355
|
enumeration(row.type, DNS_TYPES, `${label} type`);
|
|
358
356
|
const observations = array(row.observations, `${label} observations`, 16);
|
|
357
|
+
if (sources && observations.length !== sources.length)
|
|
358
|
+
fail(`${label} source coverage`);
|
|
359
359
|
let complete = 0;
|
|
360
360
|
const signatures = new Set();
|
|
361
|
+
const names = new Set();
|
|
361
362
|
for (const observationCandidate of observations) {
|
|
362
|
-
const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange'], `${label} observation`);
|
|
363
|
+
const observation = exact(observationCandidate, ['label', 'source', 'state', 'values', 'ttlRange', ...(sources ? ['observedAt'] : [])], `${label} observation`);
|
|
363
364
|
text(observation.label, `${label} observation label`, 120);
|
|
364
365
|
text(observation.source, `${label} observation source`, 240);
|
|
365
366
|
const state = enumeration(observation.state, ['observed', 'partial', 'unavailable'], `${label} observation state`);
|
|
366
367
|
const values = strings(observation.values, `${label} observation values`, 500, 16_384);
|
|
368
|
+
if (sources) {
|
|
369
|
+
const name = String(observation.label).toLowerCase();
|
|
370
|
+
if (names.has(name))
|
|
371
|
+
fail(`${label} duplicate observation`);
|
|
372
|
+
names.add(name);
|
|
373
|
+
const source = sources.find((item) => item.label === observation.label);
|
|
374
|
+
if (!source || ['label', 'source', 'state', 'observedAt'].some((key) => observation[key] !== source[key]))
|
|
375
|
+
fail(`${label} source provenance`);
|
|
376
|
+
iso(observation.observedAt, `${label} observation time`);
|
|
377
|
+
}
|
|
367
378
|
if (state === 'observed') {
|
|
368
379
|
complete += 1;
|
|
369
380
|
signatures.add(JSON.stringify(values));
|
|
@@ -381,15 +392,17 @@ function validateReviewMatrix(value, label) {
|
|
|
381
392
|
fail(`${label} state`);
|
|
382
393
|
}
|
|
383
394
|
}
|
|
384
|
-
function validateDomainChangeReview(value, label) {
|
|
385
|
-
const
|
|
386
|
-
|
|
395
|
+
function validateDomainChangeReview(value, label, version) {
|
|
396
|
+
const retainedTimes = version >= 2;
|
|
397
|
+
const review = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'state', 'authoritativeRecordMatrix', 'resolverDivergenceMatrix', 'dnssecAutomation', 'acmeDependencies', 'certificate', 'services', 'hsts', 'gate', 'limitations', ...(retainedTimes ? ['sourceObservations'] : [])], label);
|
|
398
|
+
if (review.schema !== DOMAIN_CHANGE_REVIEW_SCHEMA || review.version !== version)
|
|
387
399
|
fail(label);
|
|
388
400
|
iso(review.generatedAt, `${label} generatedAt`);
|
|
389
401
|
domain(review.domain, `${label} domain`);
|
|
390
402
|
enumeration(review.state, ['ready', 'review'], `${label} state`);
|
|
391
|
-
|
|
392
|
-
validateReviewMatrix(review.
|
|
403
|
+
const sources = retainedTimes ? exact(review.sourceObservations, ['authorities', 'resolvers'], `${label} source observations`) : null;
|
|
404
|
+
validateReviewMatrix(review.authoritativeRecordMatrix, `${label} authority matrix`, sources ? validateSourceObservations(sources.authorities, `${label} authority sources`) : undefined);
|
|
405
|
+
validateReviewMatrix(review.resolverDivergenceMatrix, `${label} resolver matrix`, sources ? validateSourceObservations(sources.resolvers, `${label} resolver sources`) : undefined);
|
|
393
406
|
const automation = exact(review.dnssecAutomation, ['state', 'cdsObserved', 'cdnskeyObserved', 'csyncObserved', 'conflictingTypes', 'detail'], `${label} DNSSEC automation`);
|
|
394
407
|
enumeration(automation.state, ['not_observed', 'conflict', 'partial', 'review_ready'], `${label} DNSSEC automation`);
|
|
395
408
|
boolean(automation.cdsObserved, `${label} CDS observed`);
|
|
@@ -405,8 +418,13 @@ function validateDomainChangeReview(value, label) {
|
|
|
405
418
|
optionalText(dependency.provider, `${label} ACME provider`, 120);
|
|
406
419
|
enumeration(dependency.state, ['confirmed', 'partial', 'unknown'], `${label} ACME state`);
|
|
407
420
|
}
|
|
408
|
-
const certificate = exact(review.certificate, ['state', 'continuity', 'findings'], `${label} certificate`);
|
|
421
|
+
const certificate = exact(review.certificate, ['state', 'continuity', 'findings', ...(retainedTimes ? ['observedAt'] : [])], `${label} certificate`);
|
|
409
422
|
enumeration(certificate.state, ['not_supplied', 'observed', 'partial', 'unavailable'], `${label} certificate state`);
|
|
423
|
+
if (retainedTimes) {
|
|
424
|
+
iso(certificate.observedAt, `${label} certificate observedAt`, true);
|
|
425
|
+
if ((certificate.state === 'not_supplied') !== (certificate.observedAt === null))
|
|
426
|
+
fail(`${label} certificate observation time`);
|
|
427
|
+
}
|
|
410
428
|
enumeration(certificate.continuity, ['unknown', 'retained', 'changes'], `${label} certificate continuity`);
|
|
411
429
|
strings(certificate.findings, `${label} certificate findings`, 8, 600);
|
|
412
430
|
for (const candidate of array(review.services, `${label} services`, 1_000)) {
|
|
@@ -534,6 +552,10 @@ function expectedDomainChangeSummary(before, after) {
|
|
|
534
552
|
}
|
|
535
553
|
function validateDomainChangePacket(value) {
|
|
536
554
|
const root = exact(value, ['schema', 'version', 'generatedAt', 'domain', 'reference', 'state', 'gate', 'summary', 'evidence', 'limitations', 'integrity'], 'Domain change packet');
|
|
555
|
+
const version = integer(root.version, 'Domain change packet version', 1, 1_000);
|
|
556
|
+
const reviewVersion = DOMAIN_CHANGE_PACKET_REVIEW_VERSIONS[version];
|
|
557
|
+
if (reviewVersion === undefined)
|
|
558
|
+
fail('Domain change packet version');
|
|
537
559
|
iso(root.generatedAt, 'Domain change packet generatedAt');
|
|
538
560
|
domain(root.domain, 'Domain change packet domain');
|
|
539
561
|
text(root.reference, 'Domain change packet reference', 200);
|
|
@@ -556,8 +578,8 @@ function validateDomainChangePacket(value) {
|
|
|
556
578
|
enumeration(summary.postChangeState, ['ready', 'review'], 'Domain change packet post-change state');
|
|
557
579
|
enumeration(summary.assuranceState, ['incomplete', 'needs_review', 'ready'], 'Domain change packet assurance state');
|
|
558
580
|
const evidence = exact(root.evidence, ['preChange', 'postChange', 'assurance'], 'Domain change packet evidence');
|
|
559
|
-
const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review');
|
|
560
|
-
const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review');
|
|
581
|
+
const pre = validateDomainChangeReview(evidence.preChange, 'Domain change packet pre-change review', reviewVersion);
|
|
582
|
+
const post = validateDomainChangeReview(evidence.postChange, 'Domain change packet post-change review', reviewVersion);
|
|
561
583
|
const assurance = validatePlannedAssurance(evidence.assurance, 'Domain change packet assurance');
|
|
562
584
|
const assuranceResult = assurance.result;
|
|
563
585
|
if (pre.generatedAt !== root.generatedAt || post.generatedAt !== root.generatedAt || assurance.generatedAt !== root.generatedAt
|
|
@@ -584,7 +606,7 @@ function validateDomainChangePacket(value) {
|
|
|
584
606
|
}))
|
|
585
607
|
fail('Domain change packet summary');
|
|
586
608
|
strings(root.limitations, 'Domain change packet limitations', 8, 600);
|
|
587
|
-
validateIntegrity(root.integrity, 'Domain change packet integrity', root.version,
|
|
609
|
+
validateIntegrity(root.integrity, 'Domain change packet integrity', root.version, version);
|
|
588
610
|
}
|
|
589
611
|
export function validateSignedDigestArtifactStructure(schema, value) {
|
|
590
612
|
if (value.schema !== schema)
|
|
@@ -1,105 +1 @@
|
|
|
1
|
-
|
|
2
|
-
const DIGEST_RE = /^sha256:[a-f0-9]{64}$/u;
|
|
3
|
-
const DOMAIN_RE = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
|
|
4
|
-
export const HEX_DIGEST_RE = /^[a-f0-9]{64}$/u;
|
|
5
|
-
export const SEMANTIC_VERSION_RE = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u;
|
|
6
|
-
export function fail(label) {
|
|
7
|
-
throw new TypeError(`${label} has an unsupported or malformed structure.`);
|
|
8
|
-
}
|
|
9
|
-
export function record(value, label) {
|
|
10
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
11
|
-
fail(label);
|
|
12
|
-
return value;
|
|
13
|
-
}
|
|
14
|
-
export function exact(value, keys, label) {
|
|
15
|
-
const source = record(value, label);
|
|
16
|
-
const actual = Object.keys(source);
|
|
17
|
-
if (actual.length !== keys.length || actual.some((key) => !keys.includes(key)))
|
|
18
|
-
fail(label);
|
|
19
|
-
return source;
|
|
20
|
-
}
|
|
21
|
-
export function exactOptional(value, required, optional, label) {
|
|
22
|
-
const source = record(value, label);
|
|
23
|
-
const actual = Object.keys(source);
|
|
24
|
-
if (required.some((key) => !actual.includes(key))
|
|
25
|
-
|| actual.some((key) => !required.includes(key) && !optional.includes(key)))
|
|
26
|
-
fail(label);
|
|
27
|
-
return source;
|
|
28
|
-
}
|
|
29
|
-
export function text(value, label, maximum = 2_000, allowEmpty = false) {
|
|
30
|
-
if (typeof value !== 'string' || value.length > maximum || CONTROL_RE.test(value) || (!allowEmpty && !value))
|
|
31
|
-
fail(label);
|
|
32
|
-
return value;
|
|
33
|
-
}
|
|
34
|
-
export function optionalText(value, label, maximum = 2_000) {
|
|
35
|
-
if (value !== null)
|
|
36
|
-
text(value, label, maximum);
|
|
37
|
-
}
|
|
38
|
-
export function iso(value, label, nullable = false) {
|
|
39
|
-
if (nullable && value === null)
|
|
40
|
-
return;
|
|
41
|
-
const candidate = text(value, label, 64);
|
|
42
|
-
const parsed = Date.parse(candidate);
|
|
43
|
-
if (!Number.isFinite(parsed) || new Date(parsed).toISOString() !== candidate)
|
|
44
|
-
fail(label);
|
|
45
|
-
}
|
|
46
|
-
export function integer(value, label, minimum = 0, maximum = Number.MAX_SAFE_INTEGER) {
|
|
47
|
-
if (!Number.isSafeInteger(value) || Number(value) < minimum || Number(value) > maximum)
|
|
48
|
-
fail(label);
|
|
49
|
-
return Number(value);
|
|
50
|
-
}
|
|
51
|
-
export function boolean(value, label) {
|
|
52
|
-
if (typeof value !== 'boolean')
|
|
53
|
-
fail(label);
|
|
54
|
-
}
|
|
55
|
-
export function enumeration(value, allowed, label) {
|
|
56
|
-
if (typeof value !== 'string' || !allowed.includes(value))
|
|
57
|
-
fail(label);
|
|
58
|
-
return value;
|
|
59
|
-
}
|
|
60
|
-
export function array(value, label, maximum, minimum = 0) {
|
|
61
|
-
if (!Array.isArray(value) || value.length < minimum || value.length > maximum)
|
|
62
|
-
fail(label);
|
|
63
|
-
return value;
|
|
64
|
-
}
|
|
65
|
-
export function strings(value, label, maximum, textMaximum = 2_000) {
|
|
66
|
-
const values = array(value, label, maximum);
|
|
67
|
-
for (const item of values)
|
|
68
|
-
text(item, label, textMaximum);
|
|
69
|
-
return values;
|
|
70
|
-
}
|
|
71
|
-
export function digest(value, label) {
|
|
72
|
-
if (typeof value !== 'string' || !DIGEST_RE.test(value))
|
|
73
|
-
fail(label);
|
|
74
|
-
}
|
|
75
|
-
export function sameValues(left, right) {
|
|
76
|
-
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
77
|
-
}
|
|
78
|
-
export function domain(value, label) {
|
|
79
|
-
if (typeof value !== 'string' || !DOMAIN_RE.test(value))
|
|
80
|
-
fail(label);
|
|
81
|
-
}
|
|
82
|
-
export function absoluteUrl(value, label, protocols) {
|
|
83
|
-
const candidate = text(value, label, 2_048);
|
|
84
|
-
try {
|
|
85
|
-
const parsed = new URL(candidate);
|
|
86
|
-
if (!protocols.includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash)
|
|
87
|
-
fail(label);
|
|
88
|
-
return parsed;
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
return fail(label);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
export function nullableRecord(value, label) {
|
|
95
|
-
if (value !== null)
|
|
96
|
-
record(value, label);
|
|
97
|
-
}
|
|
98
|
-
export function validateIntegrity(value, label, version, currentVersion) {
|
|
99
|
-
if (version !== currentVersion)
|
|
100
|
-
fail(label);
|
|
101
|
-
const integrity = exact(value, ['algorithm', 'canonicalization', 'digestSha256'], label);
|
|
102
|
-
if (integrity.algorithm !== 'SHA-256' || integrity.canonicalization !== 'sorted-json-v2')
|
|
103
|
-
fail(label);
|
|
104
|
-
digest(integrity.digestSha256, label);
|
|
105
|
-
}
|
|
1
|
+
export * from "../../packages/evidence/artifact-structure.mjs";
|
package/cli/artifact-verify.mjs
CHANGED
|
@@ -1,24 +1,25 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { readEditableCaseExport } from "../packages/cases/case-export-input.mjs";
|
|
4
|
+
import { boundedJsonLimitsForBytes, parseBoundedJsonObject } from "./bounded-json.mjs";
|
|
4
5
|
import { validateInvestigationCapsuleStructure, validateLookupEvidenceArtifactStructure, validateOfflineArtifactStructure, validateSignedDigestArtifactStructure, } from "./offline-artifact-validation.mjs";
|
|
5
6
|
import { verifyCaseResponsePacketIntegrity, } from "../packages/cases/case-response-packet.mjs";
|
|
6
7
|
import { CASE_PORTABILITY_VERIFIER_DISPATCH, CASE_RESPONSE_PACKET_VERSION, CASE_RESPONSE_PACKET_SCHEMA, CLI_CASE_PACK_VERSION, CLI_CASE_PACK_SCHEMA, } from "../packages/contracts/case-portability.mjs";
|
|
7
8
|
import { ACQUISITION_DECISION_PACKET_SCHEMA, BULK_DOMAIN_COMPARISON_SCHEMA, BULK_MAIL_EXPOSURE_SCHEMA, BULK_REVIEW_MANIFEST_SCHEMA, LOOKUP_CLAIM_PASSPORT_SCHEMA, LOOKUP_CLAIM_PASSPORT_VERSION, SUPPORTED_ACQUISITION_DECISION_PACKET_VERSIONS, SUPPORTED_BULK_DOMAIN_COMPARISON_EXPORT_VERSIONS, SUPPORTED_BULK_MAIL_EXPOSURE_EXPORT_VERSIONS, SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS, } from "../packages/contracts/investigation-portability.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import { WORKSPACE_ARCHIVE_SCHEMA,
|
|
9
|
+
import { decryptWorkspaceArchiveWithMetadata, inspectEncryptedWorkspaceArchive, isEncryptedWorkspaceArchive, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES, } from "../packages/workspace/workspace-archive-crypto.mjs";
|
|
10
|
+
import { WORKSPACE_ARCHIVE_SCHEMA, prepareWorkspaceArchive, readWorkspaceArchive, } from "../packages/workspace/workspace-archive.mjs";
|
|
10
11
|
import { canonicalArtifactJsonFor, resolveArtifactCanonicalization, sha256ArtifactDigestFor, SORTED_JSON_V2, } from "../packages/evidence/artifact-integrity.mjs";
|
|
11
12
|
import { SAVED_LOOKUP_SCHEMA, SUPPORTED_SAVED_LOOKUP_SCHEMA_VERSIONS, parseCliLookupDocument, } from "./saved-lookup.mjs";
|
|
12
13
|
import { DOMAIN_CONTROL_MANIFEST_SCHEMA, verifyDomainControlManifest, } from "../lib/domain-control-manifest.mjs";
|
|
13
|
-
import { DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, } from "../packages/contracts/domain-control-manifest.mjs";
|
|
14
|
-
import { DOMAIN_CHANGE_PACKET_SCHEMA,
|
|
14
|
+
import { DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES, MAX_DOMAIN_CONTROL_MANIFEST_BYTES, } from "../packages/contracts/domain-control-manifest.mjs";
|
|
15
|
+
import { DOMAIN_CHANGE_PACKET_SCHEMA, SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS, } from "../lib/domain-change-packet.mjs";
|
|
15
16
|
import { INVESTIGATION_CAPSULE_SCHEMA, SUPPORTED_INVESTIGATION_CAPSULE_VERSIONS, verifyInvestigationCapsule, } from "../packages/investigation/investigation-capsule.mjs";
|
|
16
|
-
import { INVESTIGATION_MANIFEST_SCHEMA,
|
|
17
|
+
import { INVESTIGATION_MANIFEST_SCHEMA, SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS, } from "./investigation-manifest.mjs";
|
|
17
18
|
import { verifyCliCasePack } from "./case-pack.mjs";
|
|
18
19
|
import { LOOKUP_EVIDENCE_PORTABLE_MAX_BYTES, LOOKUP_EVIDENCE_SCHEMA, SUPPORTED_LOOKUP_EVIDENCE_SCHEMA_VERSIONS, } from "../lib/evidence-export.mjs";
|
|
19
20
|
export const OFFLINE_ARTIFACT_VERIFICATION_SCHEMA = 'whoisleuth.offline-artifact-verification';
|
|
20
|
-
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION =
|
|
21
|
-
export const MAX_OFFLINE_ARTIFACT_BYTES =
|
|
21
|
+
export const OFFLINE_ARTIFACT_VERIFICATION_VERSION = 4;
|
|
22
|
+
export const MAX_OFFLINE_ARTIFACT_BYTES = Math.max(MAX_DOMAIN_CONTROL_MANIFEST_BYTES, MAX_ENCRYPTED_WORKSPACE_ARCHIVE_BYTES);
|
|
22
23
|
export const MAX_OFFLINE_PASSPHRASE_FILE_BYTES = 1024;
|
|
23
24
|
export class UnsupportedOfflineArtifactError extends TypeError {
|
|
24
25
|
code = 'unsupported_artifact';
|
|
@@ -41,8 +42,8 @@ const SIGNED_ARTIFACT_ROUTES = Object.freeze({
|
|
|
41
42
|
[BULK_MAIL_EXPOSURE_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_MAIL_EXPOSURE_EXPORT_VERSIONS),
|
|
42
43
|
[BULK_REVIEW_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_BULK_REVIEW_MANIFEST_VERSIONS),
|
|
43
44
|
[DOMAIN_CONTROL_MANIFEST_SCHEMA]: DOMAIN_CONTROL_MANIFEST_CANONICALIZATION_ROUTES,
|
|
44
|
-
[DOMAIN_CHANGE_PACKET_SCHEMA]: currentCanonicalizationRoutes(
|
|
45
|
-
[INVESTIGATION_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(
|
|
45
|
+
[DOMAIN_CHANGE_PACKET_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_DOMAIN_CHANGE_PACKET_VERSIONS),
|
|
46
|
+
[INVESTIGATION_MANIFEST_SCHEMA]: currentCanonicalizationRoutes(SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS),
|
|
46
47
|
});
|
|
47
48
|
function record(value) {
|
|
48
49
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
@@ -83,8 +84,16 @@ function rejectUnsupportedCasePortabilityVersion(label, version, currentVersion)
|
|
|
83
84
|
}
|
|
84
85
|
throw new UnsupportedOfflineArtifactError(`This ${label} version is malformed or unsupported; no data was changed.`);
|
|
85
86
|
}
|
|
86
|
-
function
|
|
87
|
-
|
|
87
|
+
export function parseOfflineArtifactJson(raw) {
|
|
88
|
+
const value = parseBoundedJsonObject(raw, {
|
|
89
|
+
maximumBytes: MAX_OFFLINE_ARTIFACT_BYTES,
|
|
90
|
+
limits: boundedJsonLimitsForBytes(MAX_OFFLINE_ARTIFACT_BYTES),
|
|
91
|
+
});
|
|
92
|
+
if (value.schema !== WORKSPACE_ARCHIVE_SCHEMA && !isEncryptedWorkspaceArchive(value)
|
|
93
|
+
&& Buffer.byteLength(raw, 'utf8') > MAX_DOMAIN_CONTROL_MANIFEST_BYTES) {
|
|
94
|
+
throw new TypeError(`Non-workspace artefacts are limited to ${MAX_DOMAIN_CONTROL_MANIFEST_BYTES} bytes.`);
|
|
95
|
+
}
|
|
96
|
+
return value;
|
|
88
97
|
}
|
|
89
98
|
export function hasVerifiedArtifactStructure(report) {
|
|
90
99
|
return report.checks.structure === 'verified';
|
|
@@ -92,6 +101,7 @@ export function hasVerifiedArtifactStructure(report) {
|
|
|
92
101
|
export function hasVerifiedApplicableIntegrity(report) {
|
|
93
102
|
return report.checks.contentIntegrity === 'verified'
|
|
94
103
|
&& (report.checks.contentIntegrityScope === 'whole_artifact'
|
|
104
|
+
|| report.checks.contentIntegrityScope === 'manifest_and_files'
|
|
95
105
|
|| report.checks.contentIntegrityScope === 'embedded_projections');
|
|
96
106
|
}
|
|
97
107
|
export function hasVerifiedWholeArtifactIntegrity(report) {
|
|
@@ -108,6 +118,9 @@ export function offlineArtifactSatisfiesAssurance(report, requirement) {
|
|
|
108
118
|
return hasVerifiedWholeArtifactIntegrity(report);
|
|
109
119
|
}
|
|
110
120
|
export function isCompleteOfflineArtifactVerification(report) {
|
|
121
|
+
if (report.artifact.kind === 'bagit')
|
|
122
|
+
return report.state === 'integrity_valid' && report.bagit?.state === 'valid'
|
|
123
|
+
&& report.checks.structure === 'verified' && report.checks.contentIntegrity === 'verified';
|
|
111
124
|
return (report.state === 'verified' || report.state === 'structure_valid')
|
|
112
125
|
&& (report.manifestIdentity === null || report.manifestIdentity.state === 'identity_verified');
|
|
113
126
|
}
|
|
@@ -124,8 +137,7 @@ function artifactVersion(value) {
|
|
|
124
137
|
function inputBytes(raw) {
|
|
125
138
|
return Buffer.byteLength(raw, 'utf8');
|
|
126
139
|
}
|
|
127
|
-
|
|
128
|
-
const preview = await previewWorkspaceArchive(source, {});
|
|
140
|
+
function archiveReport(raw, archive, preview, encrypted, ciphertextBytes) {
|
|
129
141
|
const readySectionCount = preview.sections.filter((section) => section.status === 'ready').length;
|
|
130
142
|
const unsupportedSectionCount = preview.sections.filter((section) => section.status === 'unsupported').length;
|
|
131
143
|
const blockedSectionCount = preview.sections.filter((section) => section.status === 'blocked').length;
|
|
@@ -172,6 +184,24 @@ async function archiveReport(raw, source, archive, encrypted, ciphertextBytes) {
|
|
|
172
184
|
]),
|
|
173
185
|
});
|
|
174
186
|
}
|
|
187
|
+
async function verifyWorkspaceArchiveValue(raw, value, passphrase) {
|
|
188
|
+
const encrypted = isEncryptedWorkspaceArchive(value);
|
|
189
|
+
if (encrypted && !passphrase) {
|
|
190
|
+
throw new TypeError('Encrypted archive inspection requires a separate passphrase file.');
|
|
191
|
+
}
|
|
192
|
+
if (!encrypted)
|
|
193
|
+
artifactVersion(value);
|
|
194
|
+
const decrypted = encrypted ? await decryptWorkspaceArchiveWithMetadata(value, passphrase) : null;
|
|
195
|
+
const source = decrypted ? decrypted.archive : value;
|
|
196
|
+
const prepared = await prepareWorkspaceArchive(source);
|
|
197
|
+
const archive = prepared.read();
|
|
198
|
+
const report = archiveReport(raw, archive, prepared.preview({}), encrypted, decrypted?.ciphertextBytes ?? null);
|
|
199
|
+
return { archive, report, encrypted };
|
|
200
|
+
}
|
|
201
|
+
/** Verify an archive once and retain its sections for bounded offline inspection. */
|
|
202
|
+
export async function verifyOfflineWorkspaceArchive(raw, options = {}) {
|
|
203
|
+
return verifyWorkspaceArchiveValue(raw, parseOfflineArtifactJson(raw), options.passphrase);
|
|
204
|
+
}
|
|
175
205
|
async function verifySignedArtifact(raw, value, schema, version) {
|
|
176
206
|
const routes = SIGNED_ARTIFACT_ROUTES[schema] ?? [];
|
|
177
207
|
if (!routes.some((route) => route.version === version)) {
|
|
@@ -215,7 +245,7 @@ async function verifySignedArtifact(raw, value, schema, version) {
|
|
|
215
245
|
});
|
|
216
246
|
}
|
|
217
247
|
async function verifyOfflineArtifactCore(raw, options = {}) {
|
|
218
|
-
const value =
|
|
248
|
+
const value = parseOfflineArtifactJson(raw);
|
|
219
249
|
const casePortabilityVerifier = selectCasePortabilityVerifier(value);
|
|
220
250
|
if (casePortabilityVerifier?.id === 'cli-case-pack') {
|
|
221
251
|
const casePackVersion = casePortabilityVersion(casePortabilityVerifier, value);
|
|
@@ -249,9 +279,24 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
|
|
|
249
279
|
]),
|
|
250
280
|
});
|
|
251
281
|
}
|
|
282
|
+
if (value.schema === undefined && Array.isArray(value.cases)) {
|
|
283
|
+
const cases = readEditableCaseExport(raw);
|
|
284
|
+
return Object.freeze({
|
|
285
|
+
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA, version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
286
|
+
artifact: Object.freeze({ kind: 'case_export', schema: null, version: artifactVersion(value) }),
|
|
287
|
+
state: 'structure_valid',
|
|
288
|
+
checks: Object.freeze({ structure: 'verified', contentIntegrity: 'not_checked', contentIntegrityScope: 'not_applicable', authenticatedEncryption: 'not_applicable' }),
|
|
289
|
+
summary: Object.freeze({ inputBytes: inputBytes(raw), sectionCount: 1, recordCount: cases.length, ciphertextBytes: null }),
|
|
290
|
+
limitations: Object.freeze([
|
|
291
|
+
'The ordinary Case export matches its exact supported structure without repairing or removing retained records. It has no embedded checksum or signature.',
|
|
292
|
+
'Original-file references do not include original bytes. Verify a selected evidence package to check which references have matching bytes.',
|
|
293
|
+
'The Case includes private analyst content. Structural validity does not authenticate its author or establish factual accuracy, currentness or response authority.',
|
|
294
|
+
]),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
252
297
|
if (isEncryptedWorkspaceArchive(value)) {
|
|
253
|
-
const inspected = inspectEncryptedWorkspaceArchive(value);
|
|
254
298
|
if (!options.passphrase) {
|
|
299
|
+
const inspected = inspectEncryptedWorkspaceArchive(value);
|
|
255
300
|
return Object.freeze({
|
|
256
301
|
schema: OFFLINE_ARTIFACT_VERIFICATION_SCHEMA,
|
|
257
302
|
version: OFFLINE_ARTIFACT_VERIFICATION_VERSION,
|
|
@@ -279,14 +324,12 @@ async function verifyOfflineArtifactCore(raw, options = {}) {
|
|
|
279
324
|
]),
|
|
280
325
|
});
|
|
281
326
|
}
|
|
282
|
-
|
|
283
|
-
const archive = await readWorkspaceArchive(decrypted);
|
|
284
|
-
return archiveReport(raw, decrypted, archive, true, inspected.ciphertextBytes);
|
|
327
|
+
return (await verifyWorkspaceArchiveValue(raw, value, options.passphrase)).report;
|
|
285
328
|
}
|
|
286
329
|
const schema = typeof value.schema === 'string' ? value.schema : '';
|
|
287
330
|
const version = artifactVersion(value);
|
|
288
331
|
if (schema === WORKSPACE_ARCHIVE_SCHEMA) {
|
|
289
|
-
return
|
|
332
|
+
return (await verifyWorkspaceArchiveValue(raw, value)).report;
|
|
290
333
|
}
|
|
291
334
|
if (casePortabilityVerifier?.id === 'case-response-packet') {
|
|
292
335
|
if (!supportsCasePortabilityVersion(casePortabilityVerifier, version)) {
|
|
@@ -432,13 +475,13 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
|
|
|
432
475
|
|| manifestReport.checks.contentIntegrityScope !== 'whole_artifact') {
|
|
433
476
|
throw new TypeError('The selected manifest is not a fully integrity-verified investigation manifest.');
|
|
434
477
|
}
|
|
435
|
-
const manifest =
|
|
478
|
+
const manifest = parseOfflineArtifactJson(manifestRaw);
|
|
436
479
|
const entries = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
|
|
437
480
|
const entry = entries.find((candidate) => record(candidate)?.id === entryId);
|
|
438
481
|
const item = record(entry);
|
|
439
482
|
if (!item)
|
|
440
483
|
throw new TypeError('The requested investigation manifest entry was not found.');
|
|
441
|
-
const artifactValue =
|
|
484
|
+
const artifactValue = parseOfflineArtifactJson(artifactRaw);
|
|
442
485
|
const metadata = artifactMetadata(artifactValue);
|
|
443
486
|
const actualByteLength = inputBytes(artifactRaw);
|
|
444
487
|
const expectedByteLength = Number(item.byteLength);
|
|
@@ -466,7 +509,7 @@ async function verifyManifestIdentity(artifactRaw, manifestRaw, entryId) {
|
|
|
466
509
|
: 'mismatch';
|
|
467
510
|
return Object.freeze({
|
|
468
511
|
state,
|
|
469
|
-
manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version:
|
|
512
|
+
manifest: Object.freeze({ schema: INVESTIGATION_MANIFEST_SCHEMA, version: manifestReport.artifact.version, entryId }),
|
|
470
513
|
checks,
|
|
471
514
|
expectedByteLength,
|
|
472
515
|
actualByteLength,
|
|
@@ -490,8 +533,9 @@ export async function verifyOfflineArtifact(raw, options = {}) {
|
|
|
490
533
|
export function formatOfflineArtifactVerification(report) {
|
|
491
534
|
const lines = [
|
|
492
535
|
'WHOISleuth offline artefact verification',
|
|
493
|
-
`Artifact: ${report.artifact.kind} · ${report.artifact.schema} v${report.artifact.version}`,
|
|
536
|
+
`Artifact: ${report.artifact.kind} · ${report.artifact.schema ?? 'no schema identifier'} v${report.artifact.version}`,
|
|
494
537
|
`State: ${report.state}`,
|
|
538
|
+
'Automation: use --strict-exit for exit 4 on incomplete verification; default exit 0 means the report was produced.',
|
|
495
539
|
`Structure: ${report.checks.structure}`,
|
|
496
540
|
`Content integrity: ${report.checks.contentIntegrity}`,
|
|
497
541
|
`Content integrity scope: ${report.checks.contentIntegrityScope}`,
|
|
@@ -534,6 +578,28 @@ export function formatOfflineArtifactVerification(report) {
|
|
|
534
578
|
for (const limitation of report.manifestIdentity.limitations)
|
|
535
579
|
lines.push(`Manifest limitation: ${limitation}`);
|
|
536
580
|
}
|
|
581
|
+
if (report.bagit) {
|
|
582
|
+
const bag = report.bagit;
|
|
583
|
+
lines.push(`BagIt: ${bag.state}`, `Payload: ${bag.entries.length} files; ${bag.payloadBytes} present bytes`, `Tag checksums: ${bag.verifiedTagFiles} of ${bag.tagFiles} tag files checked`, `Fetch declarations: ${bag.fetchEntries}; ${bag.fetchMissing} files missing; no requests made`, `Unsupported manifests: ${bag.unsupportedManifests}`);
|
|
584
|
+
for (const entry of bag.entries)
|
|
585
|
+
lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength ?? 'unknown'} bytes`);
|
|
586
|
+
for (const issue of bag.issues)
|
|
587
|
+
lines.push(`BagIt issue: ${issue}`);
|
|
588
|
+
}
|
|
589
|
+
if (report.package) {
|
|
590
|
+
lines.push(`Package bytes: ${report.package.digestSha256}`, 'Storage: unchanged; inspection only', 'Audience: private; review selected file contents before sharing', 'Signature trust: not checked', 'Trusted timestamp: not checked', 'Factual accuracy: not established');
|
|
591
|
+
for (const entry of report.package.entries)
|
|
592
|
+
lines.push(`${entry.id}: ${entry.state} · ${entry.byteLength} bytes · ${entry.mediaType}${entry.issue ? ` · ${entry.issue}` : ''}`);
|
|
593
|
+
for (const link of report.package.links)
|
|
594
|
+
lines.push(`Source link ${link.capsuleEntryId}: ${link.state}${link.sourceEntryId ? ` (${link.sourceEntryId})` : ''}`);
|
|
595
|
+
for (const capture of report.package.captureManifests) {
|
|
596
|
+
lines.push(`Capture manifest ${capture.entryId}: ${capture.state}`);
|
|
597
|
+
for (const artifact of capture.artifacts)
|
|
598
|
+
lines.push(`Capture ${artifact.capture} ${artifact.kind}: ${artifact.state}${artifact.matchingIds.length ? ` (${artifact.matchingIds.join(', ')})` : ''}`);
|
|
599
|
+
}
|
|
600
|
+
for (const item of report.package.caseFiles)
|
|
601
|
+
lines.push(`Case files ${item.entryId} (${item.caseCount} Case${item.caseCount === 1 ? '' : 's'}): ${item.matched} of ${item.references} references matched; ${item.missing} missing`);
|
|
602
|
+
}
|
|
537
603
|
for (const limitation of report.limitations)
|
|
538
604
|
lines.push(`Limitation: ${limitation}`);
|
|
539
605
|
return `${lines.join('\n')}\n`;
|