@slicedearth/whoisleuth-cli 2.4.0 → 2.6.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/DISCLOSURE +1 -1
- package/NOTICE +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +67 -9
- package/cli/artifact-validation/lookup-evidence.mjs +18 -4
- package/cli/artifact-validation/signed-review.mjs +61 -64
- package/cli/artifact-verify.mjs +11 -2
- package/cli/assurance-arguments.mjs +56 -0
- package/cli/assurance-command-definitions.mjs +179 -0
- package/cli/assurance-command-runner.mjs +1 -1
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +1 -1
- package/cli/bulk-command-runner.mjs +6 -8
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +99 -6
- package/cli/case-pack.mjs +19 -4
- package/cli/ci-report.mjs +6 -3
- package/cli/collection-arguments.mjs +123 -0
- package/cli/collection-command-definitions.mjs +330 -0
- package/cli/collection-preflight.mjs +8 -1
- package/cli/command-argument-grammar.mjs +8 -210
- package/cli/command-definition.mjs +330 -0
- package/cli/command-reference.mjs +55 -1388
- package/cli/command-seeds.mjs +18 -0
- package/cli/compare.mjs +1 -1
- package/cli/config-profile.mjs +1 -1
- package/cli/ct-event-intake.mjs +4 -4
- package/cli/discover.mjs +2 -2
- package/cli/discovery-observation-snapshot.mjs +2 -2
- package/cli/discovery-scan-command-runner.mjs +7 -9
- package/cli/discovery-scan.mjs +1 -1
- package/cli/discovery-snapshot.mjs +2 -2
- package/cli/discovery-workflow.mjs +4 -4
- package/cli/domain-control-monitor.mjs +6 -6
- package/cli/domain-control-observations.mjs +2 -1
- package/cli/domain-feed.mjs +61 -0
- package/cli/errors.mjs +5 -6
- package/cli/evidence-arguments.mjs +46 -0
- package/cli/evidence-command-definitions.mjs +69 -0
- package/cli/evidence-command-runner.mjs +2 -3
- package/cli/evidence-signing.mjs +3 -2
- package/cli/external-observation-mapping.mjs +2 -2
- package/cli/formatters/json.mjs +1 -13
- package/cli/formatters/terminal-command-formats.mjs +6 -0
- package/cli/formatters/terminal-lookup.mjs +6 -0
- package/cli/history-arguments.mjs +39 -0
- package/cli/history-command-definitions.mjs +88 -0
- package/cli/indicator-set-command.mjs +32 -0
- package/cli/inline-command-families.mjs +6 -47
- package/cli/inline-command-runner.mjs +4 -31
- package/cli/input.mjs +2 -2
- package/cli/intake-command.mjs +147 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +3 -4
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/investigation-plan.mjs +32 -154
- package/cli/investigation-recipes.mjs +177 -0
- package/cli/investigation-run.mjs +78 -12
- package/cli/local-mmdb-review.mjs +220 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +19 -35
- package/cli/lookup-diff.mjs +10 -9
- package/cli/lookup-plan.mjs +10 -14
- package/cli/lookup-reconcile.mjs +6 -5
- package/cli/lookup-timeline.mjs +7 -6
- package/cli/mail-header-review.mjs +24 -247
- package/cli/mail-review.mjs +9 -7
- package/cli/network-arguments.mjs +51 -0
- package/cli/network-command-definitions.mjs +130 -0
- package/cli/network-command-runner.mjs +43 -31
- package/cli/offline-evidence-review.mjs +85 -6
- package/cli/posture.mjs +1 -1
- package/cli/registry-fixture-scaffold.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +8 -7
- package/cli/review-arguments.mjs +132 -0
- package/cli/review-command-definitions.mjs +279 -0
- package/cli/review-command-runner.mjs +72 -159
- package/cli/risk-calibration.mjs +14 -12
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +73 -0
- package/cli/sharing-review.mjs +5 -64
- package/cli/source-reliability.mjs +2 -2
- package/cli/support-arguments.mjs +49 -0
- package/cli/support-command-definitions.mjs +209 -0
- package/cli/support-command-runner.mjs +4 -4
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +100 -0
- package/cli/workflow-command-definitions.mjs +103 -0
- package/cli/workflow-command-runner.mjs +40 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +322 -4
- package/docs/contextual-reviews.md +178 -0
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/analyst-taxonomy.mjs +2 -40
- package/lib/application-version.mjs +1 -1
- package/lib/availability.mjs +13 -789
- package/lib/bounded-contract-normalizers.mjs +4 -0
- package/lib/bounded-json.mjs +2 -330
- package/lib/browser-library-profile.mjs +62 -84
- package/lib/candidate-provenance-bounds.mjs +2 -0
- package/lib/classify.mjs +1 -1
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/ct-query.mjs +2 -27
- package/lib/ct-response-bounds.mjs +2 -9
- package/lib/ct-search.mjs +83 -47
- package/lib/dmarc-discovery.mjs +62 -0
- package/lib/dns-convergence-review.mjs +21 -8
- package/lib/dns-delegation-health.mjs +35 -15
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +27 -11
- package/lib/dns-query-coverage.mjs +40 -0
- package/lib/domain-assurance.mjs +1 -1
- package/lib/domain-change-packet.mjs +12 -13
- package/lib/domain-change-review.mjs +32 -14
- package/lib/domain-control-flight-recorder.mjs +103 -27
- package/lib/domain-control-manifest.mjs +25 -17
- package/lib/domain-posture-analysis.mjs +75 -7
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +45 -25
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +27 -17
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +5 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- package/lib/favicon.mjs +13 -4
- package/lib/generated/cisa-kev-catalog.mjs +41 -3
- package/lib/generated/registrar-standing-catalogue.mjs +18 -10
- package/lib/generated/retire-browser-catalog.mjs +332 -19
- package/lib/generated/{unicode-confusables-17.mjs → unicode-confusables.mjs} +33 -33
- package/lib/html-signals.mjs +13 -2
- package/lib/http-evidence-bounds.mjs +2 -8
- package/lib/http-intelligence.mjs +13 -1
- package/lib/idn-confusable-policy.mjs +6 -4
- package/lib/idn-confusables.mjs +1 -1
- package/lib/interchange-fidelity-registry.mjs +10 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-contract-primitives.mjs +1 -1
- package/lib/lookup-diagnostics.mjs +1 -23
- package/lib/lookup-network-evidence-bounds.mjs +1 -10
- package/lib/lookup-progress.mjs +50 -1
- package/lib/lookup-response-contract.mjs +70 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- package/lib/operation-budget.mjs +4 -0
- package/lib/opportunity-scoring.mjs +2 -107
- package/lib/outbound-identity.mjs +1 -1
- package/lib/page-fingerprints.mjs +18 -1
- package/lib/perceptual-hash-comparison.mjs +2 -34
- package/lib/perceptual-hash.mjs +36 -10
- package/lib/portable-generator.mjs +2 -29
- package/lib/project-metadata.mjs +2 -8
- package/lib/rdap-client.mjs +11 -7
- package/lib/registrable-domain.mjs +2 -56
- package/lib/registrar-standing-contract.mjs +1 -1
- package/lib/registrar-standing.mjs +9 -13
- package/lib/registry-capabilities.mjs +2 -2
- package/lib/registry-capability-catalogue.mjs +27 -19
- package/lib/risk-calibration-summary.mjs +2 -352
- package/lib/risk-scoring.mjs +2 -345
- package/lib/scoring-evidence-quality.mjs +2 -107
- package/lib/semantic-version.mjs +2 -54
- package/lib/service-binding-dns.mjs +46 -2
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +142 -139
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/structured-data-identity.mjs +1 -1
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/threat-intelligence-manifests.mjs +1 -1
- package/lib/threat-intelligence-runtime.mjs +1 -1
- package/lib/threat-intelligence-targets.mjs +1 -1
- package/lib/threat-intelligence-types.mjs +2 -75
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +404 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +15 -14
- package/packages/analysis/analyst-taxonomy.mjs +40 -0
- package/packages/analysis/bounded-json.mjs +330 -0
- package/packages/analysis/candidate-provenance-bounds.mjs +4 -0
- package/packages/analysis/cli-argument-grammar.mjs +216 -0
- package/packages/analysis/ct-query.mjs +27 -0
- package/packages/analysis/ct-response-bounds.mjs +9 -0
- package/packages/analysis/external-intelligence-risk.mjs +124 -0
- package/packages/analysis/http-evidence-bounds.mjs +8 -0
- package/packages/analysis/lookup-network-evidence-bounds.mjs +10 -0
- package/packages/analysis/opportunity-scoring.mjs +107 -0
- package/packages/analysis/perceptual-hash-comparison.mjs +34 -0
- package/packages/analysis/portable-generator.mjs +29 -0
- package/packages/analysis/project-metadata.mjs +8 -0
- package/packages/analysis/registrable-domain.mjs +56 -0
- package/packages/analysis/risk-calibration-summary.mjs +352 -0
- package/packages/analysis/risk-scoring.mjs +348 -0
- package/packages/analysis/scoring-evidence-quality.mjs +107 -0
- package/packages/analysis/semantic-version.mjs +54 -0
- package/packages/analysis/threat-intelligence-types.mjs +75 -0
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +151 -0
- package/packages/cases/case-evidence-model.mjs +89 -12
- package/packages/cases/case-evidence-quality.mjs +5 -0
- package/packages/cases/case-export-input.mjs +7 -5
- package/packages/cases/case-incident-context.mjs +52 -0
- package/packages/cases/case-markdown.mjs +11 -0
- package/packages/cases/case-migration-model.mjs +40 -24
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +124 -36
- package/packages/cases/case-record-contracts.mjs +1 -1
- package/packages/cases/case-record-core.mjs +1 -1
- package/packages/cases/case-record-operations.mjs +111 -118
- package/packages/cases/case-record-projection.mjs +23 -4
- package/packages/cases/case-record-version-input.mjs +41 -0
- package/packages/cases/case-report.mjs +93 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +159 -1
- package/packages/cases/case-response-model.mjs +16 -1
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +108 -20
- package/packages/cases/case-response-packet-render.mjs +35 -12
- package/packages/cases/case-response-packet.mjs +55 -13
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +60 -8
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +217 -0
- package/packages/cases/case-workflow-migration.mjs +43 -0
- package/packages/cases/http-summary.mjs +6 -1
- package/packages/comparison/favicon-similarity.mjs +1 -1
- package/packages/comparison/page-similarity.mjs +1 -1
- package/packages/comparison/relationship-evidence.mjs +21 -9
- package/packages/comparison/relationship-provenance.mjs +7 -1
- package/packages/contracts/analyst-interchange.mjs +35 -15
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +360 -49
- package/packages/contracts/cli-command-semantics.mjs +5 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +25 -0
- package/packages/contracts/document-intake.mjs +11 -0
- package/packages/contracts/domain-control-flight-recorder.mjs +30 -14
- package/packages/contracts/domain-control-manifest.mjs +1 -1
- package/packages/contracts/domain-control-monitor.mjs +13 -2
- package/packages/contracts/domain-control-review.mjs +36 -15
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/har-review.mjs +2 -0
- package/packages/contracts/identity-events.mjs +4 -0
- package/packages/contracts/investigation-run.mjs +2 -0
- package/packages/contracts/ip-address.mjs +10 -0
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +37 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +144 -28
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/lookup-target.mjs +5 -1
- package/packages/evidence/observation.mjs +2 -0
- package/packages/evidence/png-pixels.mjs +117 -0
- package/packages/evidence/public-address-policy.mjs +83 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +36 -29
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +36 -0
- package/packages/interchange/managed-indicator-set.mjs +223 -0
- package/packages/interchange/misp-indicator-export.mjs +93 -0
- package/packages/interchange/sharing-policy.mjs +56 -0
- package/packages/interchange/stix-indicator-export.mjs +129 -0
- package/packages/interchange/web-capture-import.mjs +22 -15
- package/packages/investigation/acquisition-decision-packet.mjs +79 -1
- package/packages/investigation/bulk-mail-exposure.mjs +7 -13
- package/packages/investigation/capture-coverage.mjs +57 -0
- package/packages/investigation/connector-provenance-review.mjs +100 -0
- package/packages/investigation/context-review.mjs +35 -0
- package/packages/investigation/document-intake.mjs +87 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +132 -0
- package/packages/investigation/domain-transition-review.mjs +49 -0
- package/packages/investigation/evidence-coverage-ledger.mjs +9 -1
- package/packages/investigation/har-intake.mjs +68 -0
- package/packages/investigation/identity-event-intake.mjs +131 -0
- package/packages/investigation/identity-incident-review.mjs +39 -0
- package/packages/investigation/incident-sequence-review.mjs +58 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +20 -0
- package/packages/investigation/investigation-manifest.mjs +35 -9
- package/packages/investigation/link-intake.mjs +209 -0
- package/packages/investigation/lookup-asset-graph.mjs +1 -1
- package/packages/investigation/mail-authentication-review.mjs +222 -0
- package/packages/investigation/mail-header-identity.mjs +209 -0
- package/packages/investigation/message-intake.mjs +235 -0
- package/packages/investigation/page-behaviour.mjs +113 -0
- package/packages/investigation/pdf-intake.mjs +155 -0
- package/packages/investigation/platform-continuity-review.mjs +60 -0
- package/packages/investigation/qr-intake.mjs +56 -0
- package/packages/investigation/requested-action-hints.mjs +11 -0
- package/packages/investigation/selected-input-review.mjs +17 -0
- package/packages/investigation/storefront-review.mjs +70 -0
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +48 -4
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +27 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +11 -3
- package/packages/workspace/hostile-input.mjs +1 -1
- package/packages/workspace/investigation-guide.mjs +2 -2
- package/packages/workspace/investigation-template-model.mjs +26 -2
- package/packages/workspace/page-baseline.mjs +2 -2
- package/packages/workspace/relationship-observation-model.mjs +17 -13
- package/packages/workspace/shortlist-model.mjs +24 -7
- package/packages/workspace/watchlist-history.mjs +59 -16
- package/packages/workspace/watchlist-store.mjs +152 -11
- package/packages/workspace/website-snapshot-model.mjs +28 -3
- package/packages/workspace/workspace-archive-crypto.mjs +1 -1
- package/packages/workspace/workspace-archive.mjs +5 -4
- package/third-party-notices.txt +549 -16
- package/lib/hostname.mjs +0 -2
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } from "
|
|
2
|
-
import { normalizeBoundedSemanticVersion } from "
|
|
1
|
+
import { boundedJsonLimitsForBytes, parseBoundedJson, parseBoundedJsonObject } from "../analysis/bounded-json.mjs";
|
|
2
|
+
import { normalizeBoundedSemanticVersion } from "../analysis/semantic-version.mjs";
|
|
3
3
|
import { canonicalArtifactJsonV2, sha256ArtifactBytes, sha256ArtifactDigestV2, SORTED_JSON_V2 } from "../evidence/artifact-integrity.mjs";
|
|
4
4
|
import { array, digest, enumeration, exact, fail, integer, iso, strings, text, validateIntegrity } from "../evidence/artifact-structure.mjs";
|
|
5
|
+
import { readImageDerivationDeclaration } from "../evidence/image-regions.mjs";
|
|
5
6
|
import { SELECTED_FILE_MEDIA_TYPES as INVESTIGATION_FILE_MEDIA_TYPES } from "../contracts/selected-file-limits.mjs";
|
|
6
7
|
import { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
7
8
|
export { MAX_INVESTIGATION_MANIFEST_ARTIFACTS, MAX_INVESTIGATION_MANIFEST_TOTAL_BYTES, MAX_INVESTIGATION_MANIFEST_ARTIFACT_BYTES, INVESTIGATION_FILE_MEDIA_TYPES };
|
|
8
9
|
export const INVESTIGATION_MANIFEST_SCHEMA = 'whoisleuth.investigation-manifest';
|
|
9
|
-
export const INVESTIGATION_MANIFEST_VERSION =
|
|
10
|
-
export const SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS = [2, INVESTIGATION_MANIFEST_VERSION];
|
|
10
|
+
export const INVESTIGATION_MANIFEST_VERSION = 4;
|
|
11
|
+
export const SUPPORTED_INVESTIGATION_MANIFEST_VERSIONS = [2, 3, INVESTIGATION_MANIFEST_VERSION];
|
|
11
12
|
// Independently bounds the path-free metadata, not the selected file content.
|
|
12
13
|
import { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
13
14
|
export { MAX_INVESTIGATION_MANIFEST_DOCUMENT_BYTES } from "../contracts/investigation-package-limits.mjs";
|
|
@@ -78,7 +79,10 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
|
|
|
78
79
|
throw new TypeError('Manifest artefacts exceed the combined limit.');
|
|
79
80
|
totalBytes += bytes.byteLength;
|
|
80
81
|
const mediaType = enumeration(artifact.mediaType ?? (typeof artifact.content === 'string' ? 'application/json' : 'application/octet-stream'), INVESTIGATION_FILE_MEDIA_TYPES, 'Artefact media type');
|
|
81
|
-
|
|
82
|
+
const imageDerivation = artifact.imageDerivation == null ? null : readImageDerivationDeclaration(artifact.imageDerivation);
|
|
83
|
+
if (imageDerivation && mediaType !== 'image/png')
|
|
84
|
+
throw new TypeError('Image derivation requires a PNG media declaration.');
|
|
85
|
+
return { bytes, mediaType, source: sourceDeclaration(artifact.source), imageDerivation };
|
|
82
86
|
});
|
|
83
87
|
const artifacts = [];
|
|
84
88
|
for (const [index, file] of selected.entries()) {
|
|
@@ -90,7 +94,7 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
|
|
|
90
94
|
byteLength: file.bytes.byteLength,
|
|
91
95
|
contentDigestSha256: await sha256ArtifactBytes(file.bytes),
|
|
92
96
|
canonicalDigestSha256: metadata ? await sha256ArtifactDigestV2(metadata.value) : null,
|
|
93
|
-
mediaType: file.mediaType, source: file.source,
|
|
97
|
+
mediaType: file.mediaType, source: file.source, imageDerivation: file.imageDerivation,
|
|
94
98
|
}));
|
|
95
99
|
}
|
|
96
100
|
const unsigned = Object.freeze({
|
|
@@ -109,6 +113,7 @@ export async function prepareInvestigationManifest(input, generatedAt, applicati
|
|
|
109
113
|
'Source identities, observation times and media types are declarations, not independently authenticated evidence. Missing source times remain unknown.',
|
|
110
114
|
'Packaging events use the local clock. They do not establish earlier custody, a trusted timestamp, a signature, source accuracy or current validity.',
|
|
111
115
|
'Digests establish content identity. JSON parsing and declared schema metadata do not establish that a source format is supported or its claims are true.',
|
|
116
|
+
'Image derivation declares an immediate parent fingerprint and editing operations, not proof of transformation or complete redaction. Parent bytes need not be included; missing declarations leave editing history unknown.',
|
|
112
117
|
]),
|
|
113
118
|
});
|
|
114
119
|
const manifest = Object.freeze({ ...unsigned, integrity: Object.freeze({
|
|
@@ -124,7 +129,8 @@ export async function buildInvestigationManifest(input, generatedAt, application
|
|
|
124
129
|
}
|
|
125
130
|
export function validateInvestigationManifest(value) {
|
|
126
131
|
const version = integer(value.version, 'Investigation manifest version', 2, INVESTIGATION_MANIFEST_VERSION);
|
|
127
|
-
const current = version
|
|
132
|
+
const current = version >= 3;
|
|
133
|
+
const withImageDerivation = version >= 4;
|
|
128
134
|
const root = exact(value, ['schema', 'version', 'generatedAt', 'application', 'workflow', 'configuration', 'artifacts', 'steps', 'summary', 'limitations', 'integrity', ...(current ? ['audience'] : [])], 'Investigation manifest');
|
|
129
135
|
if (root.schema !== INVESTIGATION_MANIFEST_SCHEMA)
|
|
130
136
|
fail('Investigation manifest schema');
|
|
@@ -148,7 +154,7 @@ export function validateInvestigationManifest(value) {
|
|
|
148
154
|
fail('Investigation manifest step count');
|
|
149
155
|
let totalBytes = 0;
|
|
150
156
|
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}`);
|
|
157
|
+
const item = exact(candidate, ['sequence', 'id', 'schema', 'version', 'byteLength', 'contentDigestSha256', 'canonicalDigestSha256', ...(current ? ['mediaType', 'source'] : []), ...(withImageDerivation ? ['imageDerivation'] : [])], `Investigation manifest artifact ${index + 1}`);
|
|
152
158
|
if (integer(item.sequence, 'Investigation manifest artifact sequence', 1, artifacts.length) !== index + 1 || item.id !== `artifact-${index + 1}`)
|
|
153
159
|
fail('Investigation manifest artifact order');
|
|
154
160
|
if (item.schema !== null)
|
|
@@ -162,6 +168,11 @@ export function validateInvestigationManifest(value) {
|
|
|
162
168
|
digest(item.canonicalDigestSha256, 'Investigation manifest canonical digest');
|
|
163
169
|
else if (item.canonicalDigestSha256 !== null || item.schema !== null || item.version !== null)
|
|
164
170
|
fail('Opaque artefact JSON metadata');
|
|
171
|
+
if (withImageDerivation && item.imageDerivation !== null) {
|
|
172
|
+
readImageDerivationDeclaration(item.imageDerivation);
|
|
173
|
+
if (mediaType !== 'image/png')
|
|
174
|
+
fail('Image derivation media type');
|
|
175
|
+
}
|
|
165
176
|
if (current) {
|
|
166
177
|
const source = exact(item.source, ['identity', 'observedAt'], 'Artefact source');
|
|
167
178
|
if (source.identity !== null)
|
|
@@ -189,12 +200,27 @@ export async function readInvestigationManifest(raw) {
|
|
|
189
200
|
throw new TypeError('Investigation manifest failed its integrity check.');
|
|
190
201
|
return value;
|
|
191
202
|
}
|
|
203
|
+
/** Matches selected manifest entries, not unselected attachment declarations. */
|
|
204
|
+
export function investigationImageParentIncluded(manifest, artifact) {
|
|
205
|
+
if (!('imageDerivation' in artifact) || !artifact.imageDerivation)
|
|
206
|
+
return null;
|
|
207
|
+
const parent = artifact.imageDerivation.source;
|
|
208
|
+
return manifest.artifacts.some(entry => entry.id !== artifact.id
|
|
209
|
+
&& entry.contentDigestSha256 === parent.digestSha256 && entry.byteLength === parent.byteLength);
|
|
210
|
+
}
|
|
192
211
|
export function formatInvestigationManifest(manifest) {
|
|
193
212
|
const lines = ['Investigation manifest', `Workflow ${manifest.workflow}`, `Tool version ${manifest.application.version}`,
|
|
194
213
|
`Artifacts ${manifest.summary.artifactCount}`, `Total bytes ${manifest.summary.totalBytes}`,
|
|
195
214
|
`Configuration ${manifest.configuration.digestSha256 ?? 'not supplied'}`, `Integrity ${manifest.integrity.digestSha256}`, '', 'Ordered artefacts:'];
|
|
196
|
-
for (const artifact of manifest.artifacts)
|
|
215
|
+
for (const artifact of manifest.artifacts) {
|
|
197
216
|
lines.push(` ${artifact.sequence}. ${artifact.schema ?? (artifact.canonicalDigestSha256 ? 'unversioned JSON' : 'opaque file')}${artifact.version ? ` v${artifact.version}` : ''} · ${artifact.contentDigestSha256}`);
|
|
217
|
+
if ('imageDerivation' in artifact && artifact.imageDerivation) {
|
|
218
|
+
const declaration = artifact.imageDerivation;
|
|
219
|
+
lines.push(` Declared image derivation: ${declaration.method}; ${declaration.operations.join(', ')}`, ` Parent: ${declaration.source.digestSha256} · ${declaration.source.byteLength} bytes`, ` Parent entry: ${investigationImageParentIncluded(manifest, artifact) ? 'declared in this manifest' : 'not declared in this manifest'}`, ' Declared edits are not proof of transformation or complete redaction.');
|
|
220
|
+
}
|
|
221
|
+
else if ('mediaType' in artifact && artifact.mediaType.startsWith('image/'))
|
|
222
|
+
lines.push(' Editing history: not declared');
|
|
223
|
+
}
|
|
198
224
|
lines.push('', 'No source file paths or artefact contents are retained in this manifest.', '');
|
|
199
225
|
return lines.join('\n');
|
|
200
226
|
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
|
|
2
|
+
import { parseCredentialFreeHttpUrl } from "../evidence/lookup-target.mjs";
|
|
3
|
+
import { MAX_INTAKE_LINKS, MAX_INTAKE_URL_LENGTH, MAX_EMBEDDED_LINK_DEPTH, MAX_AUTH_SCOPES } from "../contracts/message-intake.mjs";
|
|
4
|
+
const EXCLUDED_INPUTS = {
|
|
5
|
+
malformed: 'Malformed or overlong links were not reviewed',
|
|
6
|
+
credentials: 'Links containing credentials were not reviewed',
|
|
7
|
+
protocol: 'Non-HTTP(S) links were not reviewed',
|
|
8
|
+
port: 'Links using non-default ports were not reviewed',
|
|
9
|
+
host: 'IP-address or non-registrable-host links were not reviewed',
|
|
10
|
+
qrNetwork: 'QR network settings were not interpreted',
|
|
11
|
+
qrContact: 'QR contact details were not interpreted',
|
|
12
|
+
qrProtocol: 'QR non-HTTP(S) payloads were not interpreted',
|
|
13
|
+
qrHost: 'QR hostnames without an HTTP(S) scheme were not offered for collection',
|
|
14
|
+
qrText: 'QR text without an HTTP(S) link was not interpreted',
|
|
15
|
+
};
|
|
16
|
+
const AUTH_KEYS = ['client_id', 'scope', 'redirect_uri', 'response_type', 'prompt'];
|
|
17
|
+
const EMBEDDED_KEYS = new Set(['url', 'u', 'target', 'redirect', 'redirect_uri', 'redirect_url', 'return', 'returnurl', 'continue', 'next', 'dest', 'destination']);
|
|
18
|
+
const safeIdentifier = (value) => value && /^[a-zA-Z0-9._~:-]{1,160}$/u.test(value) ? value : null;
|
|
19
|
+
const tokens = (value) => (value ?? '').split(/\s+/u).filter(Boolean);
|
|
20
|
+
export function reviewAuthorisationLink(url) {
|
|
21
|
+
const params = url.searchParams;
|
|
22
|
+
const authorisation = params.has('client_id') && (params.has('scope') || params.has('response_type') || params.has('redirect_uri'));
|
|
23
|
+
const device = /\/(?:device|devicecode|devicelogin)(?:\/|$)/iu.test(url.pathname);
|
|
24
|
+
if (!authorisation && !device)
|
|
25
|
+
return null;
|
|
26
|
+
const duplicateParameters = AUTH_KEYS.filter(key => params.getAll(key).length > 1);
|
|
27
|
+
const single = (key) => duplicateParameters.includes(key) ? null : params.get(key);
|
|
28
|
+
const scopes = tokens(single('scope'));
|
|
29
|
+
const retainedScopes = scopes.filter(scope => /^[a-zA-Z0-9._:/-]{1,160}$/u.test(scope)).slice(0, MAX_AUTH_SCOPES);
|
|
30
|
+
const redirect = parseCredentialFreeHttpUrl(single('redirect_uri'), MAX_INTAKE_URL_LENGTH);
|
|
31
|
+
const responseTypes = tokens(single('response_type')).filter(value => ['code', 'token', 'id_token', 'none'].includes(value));
|
|
32
|
+
const prompt = tokens(single('prompt')).filter(value => ['none', 'login', 'consent', 'select_account', 'create'].includes(value));
|
|
33
|
+
return {
|
|
34
|
+
kind: authorisation ? 'authorisation_parameters' : 'device_code_reference',
|
|
35
|
+
clientId: safeIdentifier(single('client_id')), scopes: retainedScopes,
|
|
36
|
+
redirectOrigin: redirect?.origin ?? null, responseTypes, prompt, duplicateParameters,
|
|
37
|
+
omittedParameters: scopes.length !== retainedScopes.length || (params.has('client_id') && !safeIdentifier(single('client_id')))
|
|
38
|
+
|| (params.has('redirect_uri') && !redirect) || tokens(single('response_type')).length !== responseTypes.length
|
|
39
|
+
|| tokens(single('prompt')).length !== prompt.length,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export function refangIntakeUrl(raw) {
|
|
43
|
+
// Only the scheme and authority are defanged. Path/query/fragment bytes may be
|
|
44
|
+
// significant to an exact supplied target, even when they resemble defanging.
|
|
45
|
+
return raw.replace(/^(https?|hxxps?)(?::|\[:\])\/\/([^/?#]*)/iu, (_match, scheme, authority) => `${scheme.toLowerCase().replace('hxxp', 'http')}://${authority.replace(/\[\.\]|\(\.\)/gu, '.')}`);
|
|
46
|
+
}
|
|
47
|
+
const HTTP_PREFIX = /^(?:https?|hxxps?)(?::|\[:\])\/\//iu;
|
|
48
|
+
function proseUrl(value) {
|
|
49
|
+
// Prose delimiters are not part of a URL; balanced path brackets are. Direct
|
|
50
|
+
// supplied links bypass this text-scanning cleanup entirely.
|
|
51
|
+
let result = value.replace(/[.,;!?]+$/u, '');
|
|
52
|
+
let round = 0, square = 0;
|
|
53
|
+
for (const char of result) {
|
|
54
|
+
if (char === '(')
|
|
55
|
+
round++;
|
|
56
|
+
else if (char === ')')
|
|
57
|
+
round--;
|
|
58
|
+
else if (char === '[')
|
|
59
|
+
square++;
|
|
60
|
+
else if (char === ']')
|
|
61
|
+
square--;
|
|
62
|
+
}
|
|
63
|
+
for (;;) {
|
|
64
|
+
const end = result.at(-1);
|
|
65
|
+
if (end === ')' && round < 0)
|
|
66
|
+
round++;
|
|
67
|
+
else if (end === ']' && square < 0)
|
|
68
|
+
square++;
|
|
69
|
+
else
|
|
70
|
+
return result;
|
|
71
|
+
result = result.slice(0, -1).replace(/[.,;!?]+$/u, '');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function admittedDestination(url, registrationDomain, normalisation = []) {
|
|
75
|
+
return { state: 'parsed', hostname: url.hostname, origin: url.origin, registrationDomain,
|
|
76
|
+
hasPrivateLocation: url.pathname !== '/' || Boolean(url.search || url.hash), normalisation };
|
|
77
|
+
}
|
|
78
|
+
export function projectIntakeDestination(value, allowBareHostname = false) {
|
|
79
|
+
const empty = (state) => ({ state, hostname: null, origin: null, registrationDomain: null, hasPrivateLocation: false, normalisation: [] });
|
|
80
|
+
if (value.length > MAX_INTAKE_URL_LENGTH)
|
|
81
|
+
return empty('unsupported');
|
|
82
|
+
const bounded = value.trim();
|
|
83
|
+
if (!bounded)
|
|
84
|
+
return empty('missing');
|
|
85
|
+
if (/[\s\\\p{Cf}]/u.test(bounded))
|
|
86
|
+
return empty('unsupported');
|
|
87
|
+
const explicitScheme = HTTP_PREFIX.test(bounded);
|
|
88
|
+
if (!explicitScheme && !allowBareHostname)
|
|
89
|
+
return empty('unsupported');
|
|
90
|
+
const refanged = refangIntakeUrl(explicitScheme ? bounded : `https://${bounded}`);
|
|
91
|
+
const parsed = parseCredentialFreeHttpUrl(refanged, MAX_INTAKE_URL_LENGTH);
|
|
92
|
+
const registrationDomain = parsed && canonicalRegistrableDomain(parsed.hostname);
|
|
93
|
+
if (!parsed || !registrationDomain || parsed.port)
|
|
94
|
+
return empty('unsupported');
|
|
95
|
+
return admittedDestination(parsed, registrationDomain, [
|
|
96
|
+
...(!explicitScheme ? ['assumed_https_for_hostname_parsing'] : []),
|
|
97
|
+
...(refanged !== (explicitScheme ? bounded : `https://${bounded}`) ? ['refanged_scheme_or_authority'] : []),
|
|
98
|
+
...(/[^\x00-\x7f]/u.test(bounded.split(/[/?#]/u).slice(0, explicitScheme ? 3 : 1).join('/')) ? ['unicode_hostname_to_ascii'] : []),
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
export function compareIntakeDestinations(displayed, destination) {
|
|
102
|
+
return displayed.state !== 'parsed' || destination.state !== 'parsed' ? 'insufficient_evidence'
|
|
103
|
+
: displayed.hostname === destination.hostname ? 'same_host' : 'different_host';
|
|
104
|
+
}
|
|
105
|
+
function displayedHost(value) {
|
|
106
|
+
return projectIntakeDestination(value, true).hostname;
|
|
107
|
+
}
|
|
108
|
+
/** No resolution or request occurs here; embedded targets remain supplied text. */
|
|
109
|
+
export function createLinkIntake() {
|
|
110
|
+
const links = [], targets = [];
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const exclusions = new Map();
|
|
113
|
+
let rejected = 0, bounded = false;
|
|
114
|
+
function exclude(reason, isLink = true) {
|
|
115
|
+
if (isLink)
|
|
116
|
+
rejected++;
|
|
117
|
+
exclusions.set(reason, (exclusions.get(reason) ?? 0) + 1);
|
|
118
|
+
}
|
|
119
|
+
function add(raw, source, displayed = '', parentId = null, depth = 0, location) {
|
|
120
|
+
if (links.length >= MAX_INTAKE_LINKS) {
|
|
121
|
+
bounded = true;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const value = refangIntakeUrl(raw.trim());
|
|
125
|
+
const url = parseCredentialFreeHttpUrl(value, MAX_INTAKE_URL_LENGTH);
|
|
126
|
+
if (!url) {
|
|
127
|
+
let reason = 'malformed';
|
|
128
|
+
if (value.length <= MAX_INTAKE_URL_LENGTH && !/[\x00-\x20\x7f]/u.test(value)) {
|
|
129
|
+
try {
|
|
130
|
+
const parsed = new URL(value);
|
|
131
|
+
if (!['http:', 'https:'].includes(parsed.protocol))
|
|
132
|
+
reason = 'protocol';
|
|
133
|
+
else if (parsed.username || parsed.password)
|
|
134
|
+
reason = 'credentials';
|
|
135
|
+
}
|
|
136
|
+
catch { /* Retain only the fixed malformed-input category. */ }
|
|
137
|
+
}
|
|
138
|
+
exclude(reason);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (url.port) {
|
|
142
|
+
exclude('port');
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const registrationDomain = canonicalRegistrableDomain(url.hostname);
|
|
146
|
+
if (!registrationDomain) {
|
|
147
|
+
exclude('host');
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const shown = displayedHost(displayed);
|
|
151
|
+
const key = JSON.stringify([url.href, source, parentId, shown, location ?? null]);
|
|
152
|
+
if (seen.has(key))
|
|
153
|
+
return;
|
|
154
|
+
seen.add(key);
|
|
155
|
+
const id = `link-${links.length + 1}`;
|
|
156
|
+
// Compare the already admitted canonical URL, not its differently bounded raw spelling.
|
|
157
|
+
const compared = compareIntakeDestinations(projectIntakeDestination(displayed, true), admittedDestination(url, registrationDomain));
|
|
158
|
+
links.push({ id, origin: url.origin, hostname: url.hostname, registrationDomain,
|
|
159
|
+
source, ...(location ? { location } : {}), parentId, displayedHostname: shown, displayedDestination: compared === 'insufficient_evidence' ? 'not_a_hostname' : compared,
|
|
160
|
+
hasPrivateLocation: url.pathname !== '/' || Boolean(url.search || url.hash), authorisation: reviewAuthorisationLink(url) });
|
|
161
|
+
targets.push({ id, exactUrl: url.href });
|
|
162
|
+
for (const [name, value] of url.searchParams) {
|
|
163
|
+
if (!EMBEDDED_KEYS.has(name.toLowerCase()) || !HTTP_PREFIX.test(value))
|
|
164
|
+
continue;
|
|
165
|
+
if (depth >= MAX_EMBEDDED_LINK_DEPTH) {
|
|
166
|
+
bounded = true;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
add(value, 'embedded_parameter', '', id, depth + 1, location);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function addText(text, source = 'text', location) {
|
|
173
|
+
// The caller admits bytes before this scan; iteration stops at the work bound.
|
|
174
|
+
for (const match of text.matchAll(/(?:https?|hxxps?)(?::|\[:\])\/\/[^\s<>"'`]+/giu)) {
|
|
175
|
+
add(match[0].length > MAX_INTAKE_URL_LENGTH ? match[0] : proseUrl(match[0]), source, '', null, 0, location);
|
|
176
|
+
if (links.length >= MAX_INTAKE_LINKS) {
|
|
177
|
+
bounded = true;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function addQr(text, source = 'qr', location) {
|
|
183
|
+
const value = refangIntakeUrl(text.trim());
|
|
184
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/iu.exec(value)?.[1]?.toLowerCase();
|
|
185
|
+
if (/^WIFI:/iu.test(value)) {
|
|
186
|
+
exclude('qrNetwork', false);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
if (/^(?:BEGIN:VCARD|MECARD:)/iu.test(value)) {
|
|
190
|
+
exclude('qrContact', false);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (scheme && !['http', 'https'].includes(scheme)) {
|
|
194
|
+
exclude('qrProtocol', false);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (!/(?:https?|hxxps?):\/\//iu.test(value)) {
|
|
198
|
+
exclude(displayedHost(value) ? 'qrHost' : 'qrText', false);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
addText(value, source, location);
|
|
202
|
+
}
|
|
203
|
+
return { add, addText, addQr, result: () => ({ links: [...links], targets: [...targets], rejected, bounded,
|
|
204
|
+
limitations: Object.keys(EXCLUDED_INPUTS).flatMap(reason => {
|
|
205
|
+
const count = exclusions.get(reason);
|
|
206
|
+
return count ? [`${EXCLUDED_INPUTS[reason]} (${count}).`] : [];
|
|
207
|
+
}),
|
|
208
|
+
}) };
|
|
209
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION, MAX_LOOKUP_ASSET_LIMITATIONS, } from "../contracts/investigation-portability.mjs";
|
|
2
2
|
import { MAX_PROFILE_VALUES } from "../contracts/workspace-portability.mjs";
|
|
3
3
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
4
|
-
import { canonicalRegistrableDomain } from "
|
|
4
|
+
import { canonicalRegistrableDomain } from "../analysis/registrable-domain.mjs";
|
|
5
5
|
import { createGraphInputReader, graphInputIsIncomplete } from "./lookup-asset-graph-inputs.mjs";
|
|
6
6
|
export { LOOKUP_ASSET_GRAPH_SCHEMA, LOOKUP_ASSET_GRAPH_VERSION };
|
|
7
7
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/gu;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { lexicalSegments, normalizeHeaderDomain, addressDomains } from "./mail-header-identity.mjs";
|
|
2
|
+
import { MAX_AUTHENTICATION_HEADERS, MAX_AUTHENTICATION_CLAUSES, MAX_AUTHENTICATION_VALUE_LENGTH, MAIL_AUTHENTICATION_METHODS, MAIL_AUTHENTICATION_PROPERTIES } from "../contracts/mail-authentication.mjs";
|
|
3
|
+
const KEYWORD = /^[a-z][a-z0-9_-]{0,31}$/iu;
|
|
4
|
+
const RESULTS = {
|
|
5
|
+
spf: ['pass', 'fail', 'softfail', 'neutral', 'none', 'temperror', 'permerror'],
|
|
6
|
+
dkim: ['pass', 'fail', 'policy', 'neutral', 'none', 'temperror', 'permerror'],
|
|
7
|
+
dmarc: ['pass', 'fail', 'none', 'temperror', 'permerror'],
|
|
8
|
+
arc: ['pass', 'fail', 'none'],
|
|
9
|
+
};
|
|
10
|
+
/** Remove comments, never quoted content. Keep separators so tokens cannot join. */
|
|
11
|
+
function withoutComments(value) {
|
|
12
|
+
let depth = 0, quoted = false, escaped = false, output = '';
|
|
13
|
+
for (const character of value) {
|
|
14
|
+
if (escaped) {
|
|
15
|
+
if (!depth)
|
|
16
|
+
output += character;
|
|
17
|
+
escaped = false;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (character === '\\' && (depth || quoted)) {
|
|
21
|
+
if (!depth)
|
|
22
|
+
output += character;
|
|
23
|
+
escaped = true;
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
if (depth) {
|
|
27
|
+
if (character === '(')
|
|
28
|
+
depth++;
|
|
29
|
+
else if (character === ')')
|
|
30
|
+
depth--;
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (character === '"')
|
|
34
|
+
quoted = !quoted;
|
|
35
|
+
if (!quoted && character === '(') {
|
|
36
|
+
depth = 1;
|
|
37
|
+
output += ' ';
|
|
38
|
+
}
|
|
39
|
+
else if (!quoted && character === ')')
|
|
40
|
+
return null;
|
|
41
|
+
else
|
|
42
|
+
output += character;
|
|
43
|
+
}
|
|
44
|
+
return depth || quoted || escaped ? null : output;
|
|
45
|
+
}
|
|
46
|
+
// Only the restricted, non-personal identifier projection enters a report.
|
|
47
|
+
function serviceId(value) {
|
|
48
|
+
return /^[a-z0-9_.-]{1,253}$/iu.test(value) ? value : null;
|
|
49
|
+
}
|
|
50
|
+
function unquote(value) {
|
|
51
|
+
return value.startsWith('"') ? value.slice(1, -1).replace(/\\(.)/gu, '$1') : value;
|
|
52
|
+
}
|
|
53
|
+
function domainProperty(value) {
|
|
54
|
+
const direct = normalizeHeaderDomain(value) ?? normalizeHeaderDomain(value.startsWith('@') ? value.slice(1) : '');
|
|
55
|
+
if (direct)
|
|
56
|
+
return direct;
|
|
57
|
+
const domains = addressDomains([value]);
|
|
58
|
+
return domains.length === 1 ? domains[0] : null;
|
|
59
|
+
}
|
|
60
|
+
function properties(value) {
|
|
61
|
+
const result = new Map();
|
|
62
|
+
let rest = value.trim(), count = 0;
|
|
63
|
+
while (rest) {
|
|
64
|
+
if (++count > MAX_AUTHENTICATION_CLAUSES)
|
|
65
|
+
return { result, complete: false };
|
|
66
|
+
const match = /^([a-z][a-z0-9_-]*(?:\s*\.\s*[a-z][a-z0-9_-]*)?)\s*=\s*("(?:[^"\\]|\\.)*"|[^\s";]+)(?=\s|$)/iu.exec(rest);
|
|
67
|
+
if (!match)
|
|
68
|
+
return { result, complete: false };
|
|
69
|
+
const key = match[1].replaceAll(/\s/gu, '').toLowerCase();
|
|
70
|
+
const values = result.get(key) ?? [];
|
|
71
|
+
values.push(unquote(match[2]));
|
|
72
|
+
result.set(key, values);
|
|
73
|
+
rest = rest.slice(match[0].length).trim();
|
|
74
|
+
}
|
|
75
|
+
return { result, complete: true };
|
|
76
|
+
}
|
|
77
|
+
function domainsFromProperties(values) {
|
|
78
|
+
const domains = [];
|
|
79
|
+
const duplicateProperties = [];
|
|
80
|
+
let omitted = false;
|
|
81
|
+
for (const property of MAIL_AUTHENTICATION_PROPERTIES) {
|
|
82
|
+
const supplied = values.get(property);
|
|
83
|
+
if (!supplied)
|
|
84
|
+
continue;
|
|
85
|
+
if (supplied.length !== 1) {
|
|
86
|
+
duplicateProperties.push(property);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const domain = domainProperty(supplied[0]);
|
|
90
|
+
if (domain)
|
|
91
|
+
domains.push({ property, domain });
|
|
92
|
+
else
|
|
93
|
+
omitted = true;
|
|
94
|
+
}
|
|
95
|
+
return { domains, duplicateProperties, omitted };
|
|
96
|
+
}
|
|
97
|
+
function readHeader(input, part, headerIndex, duplicateOf) {
|
|
98
|
+
const headerName = input.name;
|
|
99
|
+
const base = { part, headerIndex, headerName, duplicateOf, receiverTrust: 'not_established' };
|
|
100
|
+
const issues = new Set();
|
|
101
|
+
const invalid = (issue) => ({ ...base, authservId: null, state: 'malformed', claims: [], issues: [issue] });
|
|
102
|
+
if (input.value.length > MAX_AUTHENTICATION_VALUE_LENGTH)
|
|
103
|
+
return invalid('Header value exceeds the authentication review bound.');
|
|
104
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]|\p{Default_Ignorable_Code_Point}/u.test(input.value))
|
|
105
|
+
return invalid('Header value contains unsupported control characters.');
|
|
106
|
+
const segments = lexicalSegments(input.value, ';');
|
|
107
|
+
if (!segments)
|
|
108
|
+
return invalid('Header has unbalanced quoting or comments.');
|
|
109
|
+
const clauses = segments.slice(0, MAX_AUTHENTICATION_CLAUSES + 1).map(withoutComments);
|
|
110
|
+
if (clauses.some(value => value === null))
|
|
111
|
+
return invalid('Header comments could not be parsed.');
|
|
112
|
+
if (segments.length > MAX_AUTHENTICATION_CLAUSES + 1)
|
|
113
|
+
issues.add('Some clauses exceed the authentication review bound.');
|
|
114
|
+
if (headerName === 'received-spf') {
|
|
115
|
+
const first = /^\s*([a-z][a-z0-9_-]*)\b(.*)$/iu.exec(clauses.shift() ?? '');
|
|
116
|
+
if (!first || !KEYWORD.test(first[1]))
|
|
117
|
+
return invalid('The reported SPF result is malformed.');
|
|
118
|
+
const supplied = properties([first[2], ...clauses].join(' '));
|
|
119
|
+
const receiver = supplied.result.get('receiver');
|
|
120
|
+
const authservId = receiver?.length === 1 ? serviceId(receiver[0]) : null;
|
|
121
|
+
if (!supplied.complete)
|
|
122
|
+
issues.add('Some SPF properties could not be parsed.');
|
|
123
|
+
if (receiver && (receiver.length !== 1 || !authservId))
|
|
124
|
+
issues.add('The receiver identifier is ambiguous or outside the retained identifier format.');
|
|
125
|
+
const renamed = new Map(supplied.result);
|
|
126
|
+
for (const [key, property] of [['envelope-from', 'smtp.mailfrom'], ['helo', 'smtp.helo']]) {
|
|
127
|
+
if (supplied.result.has(key))
|
|
128
|
+
renamed.set(property, supplied.result.get(key));
|
|
129
|
+
}
|
|
130
|
+
const projected = domainsFromProperties(renamed);
|
|
131
|
+
if (projected.omitted)
|
|
132
|
+
issues.add('Some identity properties cannot be represented as domains.');
|
|
133
|
+
if (projected.duplicateProperties.length)
|
|
134
|
+
issues.add('Repeated identity properties were retained as ambiguous, without selecting a value.');
|
|
135
|
+
const result = first[1].toLowerCase();
|
|
136
|
+
if (!RESULTS.spf.includes(result))
|
|
137
|
+
issues.add('The reported SPF result is unsupported.');
|
|
138
|
+
return { ...base, authservId, state: issues.size ? 'partial' : 'parsed', claims: [{ method: 'spf', methodVersion: 1, result,
|
|
139
|
+
state: RESULTS.spf.includes(result) ? 'reported' : 'unsupported', domains: projected.domains, duplicateProperties: projected.duplicateProperties }], issues: [...issues] };
|
|
140
|
+
}
|
|
141
|
+
const first = /^\s*("(?:[^"\\]|\\.)*"|[^\s";]+)(?:\s+(\d{1,9}))?\s*$/u.exec(clauses.shift() ?? '');
|
|
142
|
+
if (!first)
|
|
143
|
+
return invalid('The authentication service identifier or header version is malformed.');
|
|
144
|
+
const authservId = serviceId(unquote(first[1]));
|
|
145
|
+
if (!authservId)
|
|
146
|
+
issues.add('The service identifier is outside the retained non-personal identifier format.');
|
|
147
|
+
if (first[2] && first[2] !== '1')
|
|
148
|
+
return { ...base, authservId, state: 'unsupported', claims: [], issues: ['The header declares an unsupported authentication-results version.'] };
|
|
149
|
+
if (clauses.length === 1 && clauses[0].trim().toLowerCase() === 'none')
|
|
150
|
+
return { ...base, authservId, state: 'none', claims: [], issues: [...issues] };
|
|
151
|
+
const claims = [];
|
|
152
|
+
if (!clauses.length)
|
|
153
|
+
issues.add('The header does not contain a result clause.');
|
|
154
|
+
for (const clause of clauses) {
|
|
155
|
+
const match = /^\s*([a-z][a-z0-9_-]*)(?:\s*\/\s*(\d{1,9}))?\s*=\s*([a-z][a-z0-9_-]*)(?=\s|$)(.*)$/iu.exec(clause);
|
|
156
|
+
if (!match || !KEYWORD.test(match[1]) || !KEYWORD.test(match[3])) {
|
|
157
|
+
issues.add('A result clause is malformed.');
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
const method = match[1].toLowerCase();
|
|
161
|
+
if (!MAIL_AUTHENTICATION_METHODS.includes(method)) {
|
|
162
|
+
issues.add('A reported authentication method is outside this review.');
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const supplied = properties(match[4]);
|
|
166
|
+
const projected = domainsFromProperties(supplied.result);
|
|
167
|
+
if (!supplied.complete)
|
|
168
|
+
issues.add('Some result properties could not be parsed.');
|
|
169
|
+
if (projected.omitted)
|
|
170
|
+
issues.add('Some identity properties cannot be represented as domains.');
|
|
171
|
+
if (projected.duplicateProperties.length)
|
|
172
|
+
issues.add('Repeated identity properties were retained as ambiguous, without selecting a value.');
|
|
173
|
+
const known = method, result = match[3].toLowerCase(), methodVersion = Number(match[2] ?? 1);
|
|
174
|
+
const state = methodVersion === 1 && RESULTS[known].includes(result) ? 'reported' : 'unsupported';
|
|
175
|
+
if (state === 'unsupported')
|
|
176
|
+
issues.add('A method version or result is unsupported.');
|
|
177
|
+
claims.push({ method: known, methodVersion, result, state, domains: projected.domains, duplicateProperties: projected.duplicateProperties });
|
|
178
|
+
}
|
|
179
|
+
return { ...base, authservId, state: issues.size ? claims.length ? 'partial' : 'malformed' : 'parsed', claims, issues: [...issues] };
|
|
180
|
+
}
|
|
181
|
+
/** Physical header order is preserved. Equal service names never imply trust. */
|
|
182
|
+
export function reviewMailAuthentication(fields, part = 1, maximumHeaders = MAX_AUTHENTICATION_HEADERS) {
|
|
183
|
+
if (!Number.isSafeInteger(part) || part < 1 || !Number.isSafeInteger(maximumHeaders) || maximumHeaders < 0 || maximumHeaders > MAX_AUTHENTICATION_HEADERS)
|
|
184
|
+
throw new TypeError('Invalid authentication review bounds.');
|
|
185
|
+
const headers = [], previous = new Map();
|
|
186
|
+
let omittedHeaders = 0;
|
|
187
|
+
for (const [index, field] of fields.entries()) {
|
|
188
|
+
if (!['authentication-results', 'received-spf'].includes(field.name))
|
|
189
|
+
continue;
|
|
190
|
+
if (headers.length >= maximumHeaders) {
|
|
191
|
+
omittedHeaders++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
// Do not retain an oversized raw value in the temporary duplicate index.
|
|
195
|
+
const key = field.value.length <= MAX_AUTHENTICATION_VALUE_LENGTH ? `${field.name}:${field.value}` : null;
|
|
196
|
+
const duplicateOf = key === null ? null : previous.get(key) ?? null;
|
|
197
|
+
headers.push(readHeader(field, part, index + 1, duplicateOf));
|
|
198
|
+
if (key !== null && duplicateOf === null)
|
|
199
|
+
previous.set(key, index + 1);
|
|
200
|
+
}
|
|
201
|
+
return { headers, omittedHeaders };
|
|
202
|
+
}
|
|
203
|
+
export function selectReceiverTrust(review, selected) {
|
|
204
|
+
if (!Array.isArray(selected) || selected.length > MAX_AUTHENTICATION_HEADERS || new Set(selected).size !== selected.length
|
|
205
|
+
|| selected.some(value => !/^[1-9]\d{0,5}:[1-9]\d{0,5}$/u.test(value)
|
|
206
|
+
|| !review.headers.some(header => `${header.part}:${header.headerIndex}` === value && header.authservId !== null && ['parsed', 'partial', 'none'].includes(header.state)))) {
|
|
207
|
+
throw new TypeError('Select each recognised receiver header by part:header-index; unidentified, malformed and unsupported headers cannot be trusted.');
|
|
208
|
+
}
|
|
209
|
+
return { ...review, headers: review.headers.map(header => ({ ...header, receiverTrust: selected.includes(`${header.part}:${header.headerIndex}`) ? 'analyst_selected' : 'not_established' })) };
|
|
210
|
+
}
|
|
211
|
+
export function authenticationHeaderLabel(header) {
|
|
212
|
+
return `Part ${header.part}, header ${header.headerIndex} · ${header.headerName} · ${header.authservId ?? 'service unidentified'}`;
|
|
213
|
+
}
|
|
214
|
+
/** Compatibility summary only: all parsed sources, with no receiver-trust verdict. */
|
|
215
|
+
export function aggregateMailAuthentication(review) {
|
|
216
|
+
const known = new Set(['fail', 'neutral', 'none', 'pass', 'permerror', 'softfail', 'temperror']);
|
|
217
|
+
return Object.fromEntries(MAIL_AUTHENTICATION_METHODS.map(method => {
|
|
218
|
+
const claims = review.headers.flatMap(header => header.claims.filter(claim => claim.method === method));
|
|
219
|
+
const states = new Set(claims.map(claim => claim.state === 'reported' && known.has(claim.result) ? claim.result : 'unknown'));
|
|
220
|
+
return [method, { state: states.size > 1 ? 'mixed' : [...states][0] ?? 'unknown', observations: claims.length }];
|
|
221
|
+
}));
|
|
222
|
+
}
|