@slicedearth/whoisleuth-cli 2.4.0 → 2.5.1
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 +2 -2
- package/cli/argument-values.mjs +55 -0
- package/cli/arguments.mjs +24 -470
- package/cli/artifact-validation/case-response.mjs +21 -6
- package/cli/artifact-validation/lookup-evidence.mjs +3 -3
- package/cli/artifact-validation/signed-review.mjs +59 -13
- package/cli/artifact-verify.mjs +2 -0
- 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 +2 -2
- package/cli/bulk.mjs +2 -3
- package/cli/case-command.mjs +20 -3
- package/cli/case-pack.mjs +12 -2
- 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 +329 -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 +3 -3
- 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/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/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 +33 -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 +107 -0
- package/cli/interchange-report.mjs +4 -2
- package/cli/investigation-artifacts.mjs +1 -1
- 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 +197 -17
- package/cli/lookalike-calibration.mjs +1 -1
- package/cli/lookup-brief.mjs +2 -2
- package/cli/lookup-command-runner.mjs +18 -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 +39 -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 +131 -0
- package/cli/review-command-definitions.mjs +273 -0
- package/cli/review-command-runner.mjs +68 -158
- package/cli/risk-calibration.mjs +8 -8
- package/cli/runner.mjs +20 -32
- package/cli/saved-lookup.mjs +2 -2
- package/cli/selected-input-worker.mjs +63 -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 +2 -2
- package/cli/workflow-arguments.mjs +86 -0
- package/cli/workflow-command-definitions.mjs +78 -0
- package/cli/workflow-command-runner.mjs +8 -1
- package/docs/cli-reference.md +27 -0
- package/docs/cli.md +223 -2
- package/docs/contextual-reviews.md +155 -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/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 +12 -8
- package/lib/dns-inheritance-review.mjs +53 -63
- package/lib/dns-intelligence.mjs +9 -7
- 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 +22 -5
- package/lib/domain-posture.mjs +16 -15
- package/lib/domain-registration.mjs +392 -0
- package/lib/evidence-export-privacy.mjs +22 -15
- package/lib/evidence-export.mjs +3 -3
- package/lib/evidence-report.mjs +4 -1
- package/lib/external-intelligence-risk.mjs +2 -124
- 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 +7 -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-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 +69 -40
- package/lib/lookup-response.mjs +5 -15
- package/lib/lookup-source-progress.mjs +8 -22
- package/lib/lookup.mjs +1 -1
- 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 +27 -0
- package/lib/smtp-transport-review.mjs +48 -19
- package/lib/sslbl-certificates.generated.mjs +141 -139
- package/lib/static-html-analysis.mjs +1 -1
- package/lib/structured-data-identity.mjs +1 -1
- 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/typosquat-generator.mjs +11 -2
- package/lib/website-enrichment.mjs +388 -0
- package/lib/whois-authority.mjs +43 -5
- package/lib/whois-normalization.mjs +3 -1
- package/lib/whois-parser.mjs +5 -3
- package/package.json +13 -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 +345 -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-evidence-links.mjs +135 -0
- package/packages/cases/case-evidence-model.mjs +78 -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-recheck-model.mjs +90 -32
- 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 +40 -112
- package/packages/cases/case-record-projection.mjs +8 -1
- package/packages/cases/case-record-version-input.mjs +19 -0
- package/packages/cases/case-report.mjs +63 -27
- package/packages/cases/case-requested-evidence.mjs +146 -0
- package/packages/cases/case-response-actions.mjs +103 -1
- package/packages/cases/case-response-model.mjs +9 -1
- package/packages/cases/case-response-outcomes.mjs +29 -8
- package/packages/cases/case-response-packet-render.mjs +13 -9
- package/packages/cases/case-response-packet.mjs +25 -11
- package/packages/cases/case-response-records.mjs +0 -1
- package/packages/cases/case-response-review-inputs.mjs +14 -3
- package/packages/cases/case-storage-model.mjs +1 -1
- package/packages/cases/case-workflow-metadata.mjs +225 -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 +28 -10
- package/packages/contracts/case-portability.mjs +118 -25
- package/packages/contracts/cli-command-semantics.mjs +2 -0
- package/packages/contracts/cli-grammar.mjs +6 -0
- package/packages/contracts/cli-lookup-brief.mjs +2 -0
- package/packages/contracts/context-review.mjs +24 -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/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/mail-authentication.mjs +5 -0
- package/packages/contracts/message-intake.mjs +31 -0
- package/packages/contracts/offline-comparison.mjs +4 -1
- package/packages/contracts/passive-mail.mjs +10 -0
- package/packages/contracts/web-capture.mjs +2 -2
- package/packages/contracts/workspace-portability.mjs +63 -17
- package/packages/evidence/collection-quality.mjs +67 -0
- package/packages/evidence/dns-query-outcome.mjs +72 -0
- package/packages/evidence/email-recipient.mjs +18 -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/interchange/defensive-indicator-export.mjs +264 -0
- package/packages/interchange/external-findings-import.mjs +4 -12
- package/packages/interchange/managed-indicator-export.mjs +35 -0
- package/packages/interchange/managed-indicator-set.mjs +210 -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 +1 -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 +81 -0
- package/packages/investigation/docx-intake.mjs +133 -0
- package/packages/investigation/domain-history-review.mjs +100 -0
- package/packages/investigation/domain-transition-review.mjs +47 -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/intake-report.mjs +19 -0
- package/packages/investigation/investigation-manifest.mjs +2 -2
- package/packages/investigation/link-intake.mjs +179 -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 +195 -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/workspace/brand-profile-model.mjs +2 -2
- package/packages/workspace/bulk-review-model.mjs +28 -26
- package/packages/workspace/bulk-session-model.mjs +23 -13
- package/packages/workspace/campaign-model.mjs +1 -1
- package/packages/workspace/detection-rule-model.mjs +1 -1
- 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 +14 -5
- package/packages/workspace/watchlist-history.mjs +28 -12
- package/packages/workspace/watchlist-store.mjs +17 -5
- 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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { array, boolean, enumeration, exact, integer, text, HEX_DIGEST_RE } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { MESSAGE_ACTION_HINTS } from "../contracts/message-intake.mjs";
|
|
3
|
+
import { readCaptureCoverage, captureCoverageIsPartial, captureAttemptDescription } from "./capture-coverage.mjs";
|
|
4
|
+
export const PAGE_BEHAVIOUR_VERSION = 1;
|
|
5
|
+
// Separate from the capture's request, DOM traversal and aggregate byte bounds.
|
|
6
|
+
export const MAX_PAGE_OBSERVATIONS = 500;
|
|
7
|
+
export const PAGE_ACTION_HINTS = MESSAGE_ACTION_HINTS;
|
|
8
|
+
export function pageObservationOrigin(raw, base) {
|
|
9
|
+
if (typeof raw !== 'string' || raw.length > 8_192 || /[\u0000-\u0020\u007f]/u.test(raw))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const url = new URL(raw, base);
|
|
13
|
+
return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password ? url.origin : null;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function origin(value, nullable = false) {
|
|
20
|
+
if (nullable && value === null)
|
|
21
|
+
return null;
|
|
22
|
+
const candidate = text(value, 'Page observation origin', 500);
|
|
23
|
+
if (pageObservationOrigin(candidate) !== candidate)
|
|
24
|
+
throw new TypeError('Page observations retain origins only.');
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
function hash(value) {
|
|
28
|
+
if (value === null)
|
|
29
|
+
return null;
|
|
30
|
+
if (typeof value !== 'string' || !HEX_DIGEST_RE.test(value))
|
|
31
|
+
throw new TypeError('Page observation digest must be SHA-256.');
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function ordered(values) {
|
|
35
|
+
if (values.some((value, index) => index > 0 && value.position <= values[index - 1].position))
|
|
36
|
+
throw new TypeError('Page observation positions must be unique and increasing.');
|
|
37
|
+
return values;
|
|
38
|
+
}
|
|
39
|
+
export function readPageBehaviour(raw) {
|
|
40
|
+
const value = exact(raw, ['version', 'state', 'requests', 'elements', 'actionHints', 'clipboardWriteAttempts', 'coverage'], 'Page behaviour');
|
|
41
|
+
if (value.version !== PAGE_BEHAVIOUR_VERSION)
|
|
42
|
+
throw new TypeError('Unsupported page behaviour version.');
|
|
43
|
+
const requests = ordered(array(value.requests, 'Page requests', MAX_PAGE_OBSERVATIONS).map(raw => {
|
|
44
|
+
const item = exact(raw, ['position', 'kind', 'origin', 'contentSha256', 'status', 'cspEnforced', 'cspReportOnly'], 'Page request');
|
|
45
|
+
boolean(item.cspEnforced, 'Enforced policy presence');
|
|
46
|
+
boolean(item.cspReportOnly, 'Report-only policy presence');
|
|
47
|
+
const kind = enumeration(item.kind, ['navigation', 'script', 'frame', 'document'], 'Page request kind'), contentSha256 = hash(item.contentSha256);
|
|
48
|
+
if (kind !== 'script' && contentSha256 !== null)
|
|
49
|
+
throw new TypeError('Only script response bodies are hashed in page observations.');
|
|
50
|
+
return { position: integer(item.position, 'Request position', 1, 10_000), kind, origin: origin(item.origin), contentSha256,
|
|
51
|
+
status: integer(item.status, 'Response status', 100, 599), cspEnforced: item.cspEnforced, cspReportOnly: item.cspReportOnly };
|
|
52
|
+
}));
|
|
53
|
+
const elements = ordered(array(value.elements, 'Page elements', MAX_PAGE_OBSERVATIONS).map(raw => {
|
|
54
|
+
const item = exact(raw, ['position', 'kind', 'origin', 'inline', 'integrity', 'method', 'passwordFields', 'scriptSha256'], 'Page element');
|
|
55
|
+
const kind = enumeration(item.kind, ['script', 'frame', 'form'], 'Page element kind');
|
|
56
|
+
boolean(item.inline, 'Inline script');
|
|
57
|
+
const method = item.method === null ? null : enumeration(item.method, ['get', 'post', 'other'], 'Form method');
|
|
58
|
+
const passwordFields = integer(item.passwordFields, 'Password field count', 0, 20_000), scriptSha256 = hash(item.scriptSha256);
|
|
59
|
+
const integrity = enumeration(item.integrity, ['present', 'absent', 'not_applicable'], 'Integrity attribute');
|
|
60
|
+
if (kind !== 'form' && (method !== null || passwordFields !== 0) || kind === 'form' && method === null
|
|
61
|
+
|| kind !== 'script' && (item.inline || scriptSha256 !== null || integrity !== 'not_applicable')
|
|
62
|
+
|| kind === 'script' && (item.inline && item.origin !== null || !item.inline && scriptSha256 !== null))
|
|
63
|
+
throw new TypeError('Page element fields do not match their kind.');
|
|
64
|
+
return { position: integer(item.position, 'Element position', 1, 20_000), kind, origin: origin(item.origin, true), inline: item.inline, integrity, method, passwordFields, scriptSha256 };
|
|
65
|
+
}));
|
|
66
|
+
const actionHints = array(value.actionHints, 'Requested-action hints', PAGE_ACTION_HINTS.length).map(raw => enumeration(raw, PAGE_ACTION_HINTS, 'Requested-action hint'));
|
|
67
|
+
if (new Set(actionHints).size !== actionHints.length)
|
|
68
|
+
throw new TypeError('Page action hints must not repeat.');
|
|
69
|
+
const coverage = readCaptureCoverage(value.coverage);
|
|
70
|
+
if (value.state === 'observed' && captureCoverageIsPartial(coverage))
|
|
71
|
+
throw new TypeError('Partial request coverage cannot declare complete page observations.');
|
|
72
|
+
return { version: PAGE_BEHAVIOUR_VERSION, state: enumeration(value.state, ['observed', 'partial'], 'Page observation state'), requests, elements, actionHints,
|
|
73
|
+
clipboardWriteAttempts: integer(value.clipboardWriteAttempts, 'Blocked clipboard attempts', 0, 1_000_000), coverage };
|
|
74
|
+
}
|
|
75
|
+
export function readManifestPageBehaviour(value, manifestVersion) {
|
|
76
|
+
if (manifestVersion === 2 && value === undefined)
|
|
77
|
+
return null;
|
|
78
|
+
if (manifestVersion !== 3)
|
|
79
|
+
throw new TypeError('Page behaviour requires capture manifest version 3.');
|
|
80
|
+
return readPageBehaviour(value);
|
|
81
|
+
}
|
|
82
|
+
/** A missing/partial observation cannot establish removal or unchanged content. */
|
|
83
|
+
export function pageBehaviourRows(input) {
|
|
84
|
+
return [
|
|
85
|
+
...input.requests.map(value => ({ identity: JSON.stringify(['request', value.kind, value.origin, value.contentSha256, value.status, value.cspEnforced, value.cspReportOnly]),
|
|
86
|
+
label: `${value.kind} response ${value.position} · HTTP ${value.status} · enforced CSP ${value.cspEnforced ? 'present' : 'absent'} · report-only CSP ${value.cspReportOnly ? 'present' : 'absent'}`, origin: value.origin, digest: value.contentSha256 })),
|
|
87
|
+
...input.elements.map(value => ({ identity: JSON.stringify(['element', value.kind, value.origin, value.inline, value.integrity, value.method, value.passwordFields, value.scriptSha256]),
|
|
88
|
+
label: `${value.kind} element ${value.position}${value.inline ? ' · inline' : ''}${value.kind === 'form' ? ` · ${value.method} · ${value.passwordFields} password fields` : value.kind === 'script' ? ` · integrity attribute ${value.integrity}` : ''}`,
|
|
89
|
+
origin: value.origin, digest: value.scriptSha256 })),
|
|
90
|
+
...input.actionHints.map(value => ({ identity: JSON.stringify(['action_wording', value]), label: `Body text wording · ${value.replaceAll('_', ' ')}`, origin: null, digest: null })),
|
|
91
|
+
...input.coverage.attempts.filter(value => !['navigation', 'script', 'frame', 'document'].includes(value.channel) || value.state !== 'observed').map(value => ({
|
|
92
|
+
identity: JSON.stringify(['request_channel', value.channel, value.method, value.origin, value.state, value.reason, value.collectionStarted]),
|
|
93
|
+
label: `${value.channel.replaceAll('_', ' ')} request ${value.position} · ${captureAttemptDescription(value)}`,
|
|
94
|
+
origin: value.origin, digest: null,
|
|
95
|
+
})),
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
export function comparePageBehaviour(before, after) {
|
|
99
|
+
const counts = (input) => { const map = new Map(); for (const row of input ? pageBehaviourRows(input) : [])
|
|
100
|
+
map.set(row.identity, { ...row, count: (map.get(row.identity)?.count ?? 0) + 1 }); return map; };
|
|
101
|
+
const left = counts(before), right = counts(after), complete = before?.state === 'observed' && after?.state === 'observed';
|
|
102
|
+
const difference = (a, b) => [...a.values()].filter(row => row.count > (b.get(row.identity)?.count ?? 0))
|
|
103
|
+
.map(row => ({ ...row, count: row.count - (b.get(row.identity)?.count ?? 0) }));
|
|
104
|
+
const added = difference(right, left), notReobserved = difference(left, right);
|
|
105
|
+
const navigation = (value) => value.requests.filter(row => row.kind === 'navigation').map(row => [row.origin, row.status]);
|
|
106
|
+
const navigationChanged = before && after ? JSON.stringify(navigation(before)) !== JSON.stringify(navigation(after)) : null;
|
|
107
|
+
const clipboardWriteDelta = before && after ? after.clipboardWriteAttempts - before.clipboardWriteAttempts : null;
|
|
108
|
+
const requestChannelsChanged = before && after ? JSON.stringify(before.coverage) !== JSON.stringify(after.coverage) : null;
|
|
109
|
+
return { state: !before || !after ? 'unavailable' : complete ? added.length || notReobserved.length || navigationChanged || clipboardWriteDelta || requestChannelsChanged ? 'changed' : 'unchanged' : 'inconclusive',
|
|
110
|
+
navigationChanged, clipboardWriteDelta, requestChannelsChanged,
|
|
111
|
+
added: before ? added : [], notReobserved: after ? notReobserved : [], removalEstablished: false,
|
|
112
|
+
boundary: 'Selected capture observations, not proof of authorisation, worldwide removal or compromise. Script hashes identify collected bytes; an integrity attribute is not a verified integrity result.' };
|
|
113
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { getDocument, ImageKind, OPS } from 'pdfjs-dist/legacy/build/pdf.mjs';
|
|
2
|
+
import { createDocumentIntake } from "./document-intake.mjs";
|
|
3
|
+
import { MAX_DOCUMENT_IMAGE_PIXELS, MAX_DOCUMENT_PAGES, MAX_DOCUMENT_TEXT_BYTES, MAX_DOCUMENT_PAGE_OPERATIONS } from "../contracts/document-intake.mjs";
|
|
4
|
+
import { readEvidenceImageDimensions } from "../evidence/image-regions.mjs";
|
|
5
|
+
import { MAX_INTAKE_LINKS, MAX_INTAKE_URL_LENGTH } from "../contracts/message-intake.mjs";
|
|
6
|
+
/** No document-controlled resource can reach a filesystem or network fetcher. */
|
|
7
|
+
class NoExternalBinaryData {
|
|
8
|
+
async fetch() { throw new TypeError('External PDF resources are not loaded.'); }
|
|
9
|
+
}
|
|
10
|
+
function imagePixels(raw) {
|
|
11
|
+
if (!raw || typeof raw !== 'object')
|
|
12
|
+
throw new TypeError('The PDF image is unavailable.');
|
|
13
|
+
const value = raw;
|
|
14
|
+
const { width, height } = readEvidenceImageDimensions(value.width, value.height);
|
|
15
|
+
if (!(value.data instanceof Uint8Array) && !(value.data instanceof Uint8ClampedArray))
|
|
16
|
+
throw new TypeError('The PDF image has no supported pixel data.');
|
|
17
|
+
const data = value.data, count = width * height, pixels = new Uint8ClampedArray(count * 4);
|
|
18
|
+
if (value.kind === ImageKind.RGBA_32BPP && data.length === pixels.length)
|
|
19
|
+
pixels.set(data);
|
|
20
|
+
else if (value.kind === ImageKind.RGB_24BPP && data.length === count * 3) {
|
|
21
|
+
for (let index = 0; index < count; index++) {
|
|
22
|
+
pixels.set(data.subarray(index * 3, index * 3 + 3), index * 4);
|
|
23
|
+
pixels[index * 4 + 3] = 255;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
else if (value.kind === ImageKind.GRAYSCALE_1BPP && data.length === Math.ceil(width / 8) * height) {
|
|
27
|
+
const stride = Math.ceil(width / 8);
|
|
28
|
+
for (let y = 0; y < height; y++)
|
|
29
|
+
for (let x = 0; x < width; x++) {
|
|
30
|
+
const colour = data[y * stride + (x >> 3)] & (128 >> (x % 8)) ? 255 : 0;
|
|
31
|
+
pixels.set([colour, colour, colour, 255], (y * width + x) * 4);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
throw new TypeError('The PDF image pixel layout is unsupported.');
|
|
36
|
+
return { width, height, pixels };
|
|
37
|
+
}
|
|
38
|
+
export async function reviewPdfInput(bytes, reviewedAt, worker) {
|
|
39
|
+
const review = await createDocumentIntake(bytes, 'pdf', reviewedAt);
|
|
40
|
+
// PDF.js takes ownership of its input. The selected original stays unchanged.
|
|
41
|
+
const task = getDocument({ data: bytes.slice(), ...(worker ? { worker } : {}),
|
|
42
|
+
BinaryDataFactory: NoExternalBinaryData, useWorkerFetch: false, useWasm: false,
|
|
43
|
+
useSystemFonts: false, disableFontFace: true, enableXfa: false,
|
|
44
|
+
isOffscreenCanvasSupported: false, isImageDecoderSupported: false,
|
|
45
|
+
maxImageSize: MAX_DOCUMENT_IMAGE_PIXELS, stopAtErrors: true, verbosity: 0,
|
|
46
|
+
disableAutoFetch: true, disableStream: true, disableRange: true });
|
|
47
|
+
try {
|
|
48
|
+
const document = await task.promise;
|
|
49
|
+
const total = document.numPages, admitted = Math.min(total, MAX_DOCUMENT_PAGES);
|
|
50
|
+
review.pages(total, 0);
|
|
51
|
+
if (total > admitted)
|
|
52
|
+
review.partial('Further PDF pages exceed the page review bound.');
|
|
53
|
+
if (document.isPureXfa) {
|
|
54
|
+
review.unavailable('unsupported', 'XFA documents are not rendered or executed.');
|
|
55
|
+
return review.finish();
|
|
56
|
+
}
|
|
57
|
+
let textCharacters = 0;
|
|
58
|
+
for (let pageNumber = 1; pageNumber <= admitted; pageNumber++) {
|
|
59
|
+
const page = await document.getPage(pageNumber);
|
|
60
|
+
try {
|
|
61
|
+
const reader = page.streamTextContent().getReader();
|
|
62
|
+
let text = '';
|
|
63
|
+
try {
|
|
64
|
+
for (;;) {
|
|
65
|
+
const chunk = await reader.read();
|
|
66
|
+
if (chunk.done)
|
|
67
|
+
break;
|
|
68
|
+
for (const item of chunk.value.items) {
|
|
69
|
+
if (typeof item.str !== 'string')
|
|
70
|
+
continue;
|
|
71
|
+
textCharacters += item.str.length + 1;
|
|
72
|
+
if (textCharacters > MAX_DOCUMENT_TEXT_BYTES) {
|
|
73
|
+
review.partial('Further PDF text exceeds the text review bound.');
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
text += item.str + (item.hasEOL ? '\n' : ' ');
|
|
77
|
+
}
|
|
78
|
+
if (textCharacters > MAX_DOCUMENT_TEXT_BYTES) {
|
|
79
|
+
await reader.cancel();
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await review.text(text, pageNumber);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
review.partial(`Text on page ${pageNumber} could not be fully decoded.`);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
reader.releaseLock();
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const annotations = await page.getAnnotations(), destinations = [];
|
|
93
|
+
for (const annotation of annotations) {
|
|
94
|
+
if (typeof annotation.url !== 'string')
|
|
95
|
+
continue;
|
|
96
|
+
if (annotation.url.length > MAX_INTAKE_URL_LENGTH || destinations.length >= MAX_INTAKE_LINKS) {
|
|
97
|
+
review.partial(`Some links on page ${pageNumber} exceed the link review bound.`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
destinations.push(annotation.url);
|
|
101
|
+
}
|
|
102
|
+
await review.destinations(destinations, pageNumber);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
review.partial(`Links on page ${pageNumber} could not be fully decoded.`);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
const operations = await page.getOperatorList(), seen = new Set();
|
|
109
|
+
if (operations.fnArray.length > MAX_DOCUMENT_PAGE_OPERATIONS)
|
|
110
|
+
review.partial(`Image operations on page ${pageNumber} exceed the review bound.`);
|
|
111
|
+
else
|
|
112
|
+
for (let index = 0; index < operations.fnArray.length; index++) {
|
|
113
|
+
const operation = operations.fnArray[index], args = operations.argsArray[index];
|
|
114
|
+
let raw;
|
|
115
|
+
if (operation === OPS.paintImageXObject || operation === OPS.paintImageXObjectRepeat) {
|
|
116
|
+
const id = args?.[0];
|
|
117
|
+
if (typeof id !== 'string' || seen.has(id))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(id);
|
|
120
|
+
raw = await new Promise(resolve => (id.startsWith('g_') ? page.commonObjs : page.objs).get(id, resolve));
|
|
121
|
+
}
|
|
122
|
+
else if (operation === OPS.paintInlineImageXObject)
|
|
123
|
+
raw = args?.[0];
|
|
124
|
+
else
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
await review.image(imagePixels(raw), pageNumber);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
review.partial(`An image on page ${pageNumber} has unsupported or unavailable pixels.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
review.partial(`Image operations on page ${pageNumber} could not be fully decoded.`);
|
|
136
|
+
}
|
|
137
|
+
review.pages(total, pageNumber);
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
page.cleanup();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
review.partial('QR review covers decoded embedded raster images, not vector artwork or a rendered-page reconstruction. Scripts, forms, embedded files and external resources are not executed or opened.');
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
if (cause instanceof Error && cause.name === 'PasswordException')
|
|
147
|
+
review.unavailable('encrypted', 'The PDF requires a password. Review an explicitly decrypted copy; no password was requested or retained.');
|
|
148
|
+
else
|
|
149
|
+
review.unavailable('unsupported', 'The PDF could not be decoded completely within this review. The original bytes are unchanged.');
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await task.destroy();
|
|
153
|
+
}
|
|
154
|
+
return review.finish();
|
|
155
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { array, enumeration, exact, iso, text } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { pageObservationOrigin } from "./page-behaviour.mjs";
|
|
3
|
+
import { CASE_PROVIDER_OUTCOMES, CASE_OBSERVED_EFFECT_STATES } from "../cases/case-response-records.mjs";
|
|
4
|
+
import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
|
|
5
|
+
export { PLATFORM_CONTINUITY_INPUT_SCHEMA, PLATFORM_CONTINUITY_INPUT_VERSION } from "../contracts/context-review.mjs";
|
|
6
|
+
export const PLATFORM_OBJECT_TYPES = ['account', 'tenant', 'application', 'extension', 'package', 'page', 'channel', 'post'];
|
|
7
|
+
export const PLATFORM_REPORT_STATES = ['not_reported', 'submitted', 'acknowledged'];
|
|
8
|
+
export function readPlatformObjects(raw) {
|
|
9
|
+
const rows = array(raw, 'Platform objects', MAX_CONTEXT_RECORDS).map(value => {
|
|
10
|
+
const row = exact(value, ['platformOrigin', 'objectType', 'objectId', 'version', 'observedAt', 'source', 'report', 'providerOutcome', 'recheck', 'recheckedAt'], 'Platform object');
|
|
11
|
+
const platformOrigin = text(row.platformOrigin, 'Platform origin', 500);
|
|
12
|
+
if (pageObservationOrigin(platformOrigin) !== platformOrigin)
|
|
13
|
+
throw new TypeError('Platform origin must be an HTTP(S) origin without credentials, paths or query data.');
|
|
14
|
+
const objectId = text(row.objectId, 'Stable object ID', 240);
|
|
15
|
+
if (!/^[A-Za-z0-9_@.:%/+~-]+$/u.test(objectId))
|
|
16
|
+
throw new TypeError('Stable object ID must contain identifier characters, not free text.');
|
|
17
|
+
iso(row.observedAt, 'Object observation time');
|
|
18
|
+
iso(row.recheckedAt, 'Object recheck time', true);
|
|
19
|
+
const recheck = enumeration(row.recheck, CASE_OBSERVED_EFFECT_STATES, 'Object recheck');
|
|
20
|
+
if ((recheck === 'not_checked') !== (row.recheckedAt === null))
|
|
21
|
+
throw new TypeError('A recheck result needs its own observation time; unchecked objects must not carry one.');
|
|
22
|
+
if (row.recheckedAt !== null && Date.parse(row.recheckedAt) < Date.parse(row.observedAt))
|
|
23
|
+
throw new TypeError('Object recheck predates its observation.');
|
|
24
|
+
const report = enumeration(row.report, PLATFORM_REPORT_STATES, 'Report status');
|
|
25
|
+
const providerOutcome = row.providerOutcome === null ? null : enumeration(row.providerOutcome, CASE_PROVIDER_OUTCOMES, 'Provider outcome');
|
|
26
|
+
if (providerOutcome !== null && report === 'not_reported')
|
|
27
|
+
throw new TypeError('A provider outcome needs a reported object.');
|
|
28
|
+
return { platformOrigin, objectType: enumeration(row.objectType, PLATFORM_OBJECT_TYPES, 'Platform object type'), objectId,
|
|
29
|
+
version: row.version === null ? null : text(row.version, 'Object version', 100), observedAt: row.observedAt, source: text(row.source, 'Object source', 500), report, providerOutcome, recheck, recheckedAt: row.recheckedAt };
|
|
30
|
+
});
|
|
31
|
+
const identities = rows.map(row => JSON.stringify([row.platformOrigin, row.objectType, row.objectId, row.version, row.observedAt]));
|
|
32
|
+
if (new Set(identities).size !== rows.length)
|
|
33
|
+
throw new TypeError('Duplicate platform object observations must be reconciled explicitly.');
|
|
34
|
+
return rows;
|
|
35
|
+
}
|
|
36
|
+
export function groupPlatformObjects(rows) {
|
|
37
|
+
const groups = new Map();
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const key = JSON.stringify([row.platformOrigin, row.objectType, row.objectId]);
|
|
40
|
+
groups.set(key, [...(groups.get(key) ?? []), row]);
|
|
41
|
+
}
|
|
42
|
+
return [...groups.values()].map(values => values.sort((a, b) => Date.parse(a.observedAt) - Date.parse(b.observedAt)));
|
|
43
|
+
}
|
|
44
|
+
export function reviewPlatformContinuity(raw, reviewedAt) {
|
|
45
|
+
iso(reviewedAt, 'Review time');
|
|
46
|
+
const rows = readPlatformObjects(raw), groups = groupPlatformObjects(rows);
|
|
47
|
+
const observations = [];
|
|
48
|
+
for (const values of groups) {
|
|
49
|
+
const versions = new Set(values.map(row => row.version).filter(value => value !== null));
|
|
50
|
+
for (const row of values)
|
|
51
|
+
observations.push({ label: `${row.objectType} · ${row.objectId}${row.version ? ` · version ${row.version}` : ''}`, state: 'reported',
|
|
52
|
+
detail: `${row.report.replaceAll('_', ' ')}; provider outcome: ${row.providerOutcome?.replaceAll('_', ' ') ?? 'not supplied'}; independent recheck: ${row.recheck.replaceAll('_', ' ')}${row.recheckedAt ? ` at ${row.recheckedAt}` : ''}. ${versions.size > 1 ? `${versions.size} versions retained for this same declared object.` : 'No version change established.'}`,
|
|
53
|
+
source: `${row.platformOrigin} · ${row.source}`, observedAt: row.observedAt, hostname: new URL(row.platformOrigin).hostname });
|
|
54
|
+
}
|
|
55
|
+
const unresolved = rows.filter(row => ['not_checked', 'still_observed', 'changed', 'unavailable'].includes(row.recheck)).length;
|
|
56
|
+
return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'platform_continuity', reviewedAt, title: 'Platform object continuity',
|
|
57
|
+
state: rows.length ? 'reviewed' : 'partial', summary: `Recorded observations: ${rows.length}; distinct platform objects: ${groups.length}; observations needing follow-up or a qualified outcome: ${unresolved}.`, observations,
|
|
58
|
+
nextSteps: ['Use the exact object and version when preparing a provider report; do not report an entire shared platform from a hosted URL alone.', 'Record each report and recheck separately. New versions, changed content and newly observed objects require their own review.'],
|
|
59
|
+
limitations: ['Platform origin, object identity and outcomes are analyst-supplied metadata, not verified provider telemetry.', 'A matching object ID establishes only declared continuity within that platform and object type. Provider resolution is separate from independent recheck; not reproduced does not establish worldwide removal.'] };
|
|
60
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { binarize, Decoder, Detector } from '@nuintun/qrcode';
|
|
2
|
+
import { decodeEvidencePng } from "../evidence/png-pixels.mjs";
|
|
3
|
+
import { assertMessageBytes, reviewMessageInput } from "./message-intake.mjs";
|
|
4
|
+
import { readEvidenceImageDimensions } from "../evidence/image-regions.mjs";
|
|
5
|
+
export const MAX_QR_DETECTION_ATTEMPTS = 256;
|
|
6
|
+
export function decodeQrPixels(image) {
|
|
7
|
+
readEvidenceImageDimensions(image.width, image.height);
|
|
8
|
+
if (!(image.pixels instanceof Uint8ClampedArray) || image.pixels.length !== image.width * image.height * 4)
|
|
9
|
+
throw new TypeError('Invalid QR pixel surface.');
|
|
10
|
+
const luminance = new Uint8Array(image.width * image.height);
|
|
11
|
+
for (let index = 0; index < luminance.length; index++) {
|
|
12
|
+
const offset = index * 4, alpha = image.pixels[offset + 3] / 255;
|
|
13
|
+
luminance[index] = Math.round((image.pixels[offset] * 0.299 + image.pixels[offset + 1] * 0.587 + image.pixels[offset + 2] * 0.114) * alpha + 255 * (1 - alpha));
|
|
14
|
+
}
|
|
15
|
+
const texts = new Set();
|
|
16
|
+
let attempts = 0, bounded = false, structured = false;
|
|
17
|
+
// Normal and inverted symbols are both considered, without altering originals.
|
|
18
|
+
for (const inverted of [false, true]) {
|
|
19
|
+
if (inverted)
|
|
20
|
+
for (let index = 0; index < luminance.length; index++)
|
|
21
|
+
luminance[index] = 255 - luminance[index];
|
|
22
|
+
const candidates = new Detector().detect(binarize(luminance, image.width, image.height)), decoder = new Decoder();
|
|
23
|
+
let candidate = candidates.next();
|
|
24
|
+
while (!candidate.done) {
|
|
25
|
+
if (++attempts > MAX_QR_DETECTION_ATTEMPTS) {
|
|
26
|
+
bounded = true;
|
|
27
|
+
candidates.return();
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
let decoded = false;
|
|
31
|
+
try {
|
|
32
|
+
const symbol = decoder.decode(candidate.value.matrix);
|
|
33
|
+
decoded = true;
|
|
34
|
+
if (symbol.structured)
|
|
35
|
+
structured = true;
|
|
36
|
+
else if (symbol.content.length <= 8_192)
|
|
37
|
+
texts.add(symbol.content);
|
|
38
|
+
else
|
|
39
|
+
bounded = true;
|
|
40
|
+
}
|
|
41
|
+
catch { /* A finder-pattern candidate need not contain a decodable symbol. */ }
|
|
42
|
+
candidate = candidates.next(decoded);
|
|
43
|
+
}
|
|
44
|
+
if (bounded)
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
return { texts: [...texts], bounded, structured };
|
|
48
|
+
}
|
|
49
|
+
export async function reviewQrInput(bytes, reviewedAt) {
|
|
50
|
+
assertMessageBytes(bytes);
|
|
51
|
+
const { texts, bounded, structured } = decodeQrPixels(decodeEvidencePng(bytes));
|
|
52
|
+
const result = await reviewMessageInput(bytes, 'qr', reviewedAt, texts);
|
|
53
|
+
const boundsReached = [...result.report.coverage.boundsReached, ...(bounded ? ['QR candidate work'] : []), ...(structured ? ['Multi-symbol structured content requires reassembly'] : [])];
|
|
54
|
+
return { ...result, report: { ...result.report, coverage: { ...result.report.coverage,
|
|
55
|
+
state: boundsReached.length ? 'partial' : result.report.coverage.state, boundsReached } } };
|
|
56
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const HINTS = {
|
|
2
|
+
clipboard_instruction: /(?:clipboard|copy.{0,40}(?:paste|command)|paste.{0,40}(?:terminal|run|console))/iu,
|
|
3
|
+
shell_instruction: /(?:powershell|cmd\.exe|curl\s|wget\s|terminal|windows\s*\+\s*r)/iu,
|
|
4
|
+
verification_prompt: /(?:verify.{0,40}(?:human|browser)|not a robot|captcha|browser.{0,40}(?:repair|update))/iu,
|
|
5
|
+
device_code_instruction: /(?:device[ -]code|enter.{0,40}code.{0,40}(?:sign|login|log in))/iu,
|
|
6
|
+
consent_instruction: /(?:grant.{0,40}(?:access|permission)|consent|approve.{0,40}(?:application|permission))/iu,
|
|
7
|
+
};
|
|
8
|
+
/** Wording matches are review leads, never a conclusion about intent or execution. */
|
|
9
|
+
export function requestedActionHints(value) {
|
|
10
|
+
return Object.entries(HINTS).filter(([, expression]) => expression.test(value)).map(([hint]) => hint);
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** One format dispatch for browser and CLI; parsers load only when selected. */
|
|
2
|
+
export async function reviewSelectedInput(bytes, kind, reviewedAt, pdfWorker) {
|
|
3
|
+
switch (kind) {
|
|
4
|
+
case 'text':
|
|
5
|
+
case 'email':
|
|
6
|
+
case 'calendar': return (await import("./message-intake.mjs")).reviewMessageInput(bytes, kind, reviewedAt);
|
|
7
|
+
case 'qr': return (await import("./qr-intake.mjs")).reviewQrInput(bytes, reviewedAt);
|
|
8
|
+
case 'docx': return (await import("./docx-intake.mjs")).reviewDocxInput(bytes, reviewedAt);
|
|
9
|
+
case 'pdf': return (await import("./pdf-intake.mjs")).reviewPdfInput(bytes, reviewedAt, pdfWorker);
|
|
10
|
+
case 'har': return (await import("./har-intake.mjs")).reviewHarInput(bytes, reviewedAt);
|
|
11
|
+
case 'identity': return (await import("./identity-event-intake.mjs")).reviewIdentityEventInput(bytes, reviewedAt);
|
|
12
|
+
default: {
|
|
13
|
+
const unsupported = kind;
|
|
14
|
+
throw new TypeError(`Unsupported selected input: ${String(unsupported)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { array, boolean, domain, enumeration, exact, HEX_DIGEST_RE, iso, text } from "../evidence/artifact-structure.mjs";
|
|
2
|
+
import { pageObservationOrigin } from "./page-behaviour.mjs";
|
|
3
|
+
import { CONTEXT_REVIEW_SCHEMA, CONTEXT_REVIEW_VERSION, MAX_CONTEXT_RECORDS } from "../contracts/context-review.mjs";
|
|
4
|
+
export { STOREFRONT_INPUT_SCHEMA, STOREFRONT_INPUT_VERSION } from "../contracts/context-review.mjs";
|
|
5
|
+
export const STOREFRONT_FIELDS = [
|
|
6
|
+
{ id: 'brandNames', label: 'Displayed brand names', treatment: 'text' },
|
|
7
|
+
{ id: 'contactDomains', label: 'Published contact domains', treatment: 'domain' },
|
|
8
|
+
{ id: 'policyHashes', label: 'Policy text SHA-256 digests', treatment: 'hash' },
|
|
9
|
+
{ id: 'checkoutOrigins', label: 'Checkout destinations', treatment: 'origin' },
|
|
10
|
+
{ id: 'paymentMethods', label: 'Advertised payment methods', treatment: 'text' },
|
|
11
|
+
{ id: 'assetHashes', label: 'Distinctive asset SHA-256 digests', treatment: 'hash' },
|
|
12
|
+
];
|
|
13
|
+
export function readStorefrontObservation(raw) {
|
|
14
|
+
const row = exact(raw, ['hostname', 'observedAt', 'source', ...STOREFRONT_FIELDS.map(field => field.id)], 'Storefront observation');
|
|
15
|
+
domain(row.hostname, 'Storefront hostname');
|
|
16
|
+
iso(row.observedAt, 'Storefront observation time');
|
|
17
|
+
const fields = {};
|
|
18
|
+
let count = 0;
|
|
19
|
+
for (const field of STOREFRONT_FIELDS) {
|
|
20
|
+
if (row[field.id] === null) {
|
|
21
|
+
fields[field.id] = null;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const values = array(row[field.id], field.label, MAX_CONTEXT_RECORDS);
|
|
25
|
+
count += values.length;
|
|
26
|
+
if (count > MAX_CONTEXT_RECORDS)
|
|
27
|
+
throw new TypeError('A storefront observation exceeds the aggregate evidence-item bound.');
|
|
28
|
+
fields[field.id] = [...new Set(values.map(value => {
|
|
29
|
+
const candidate = text(value, field.label, 500);
|
|
30
|
+
if (field.treatment === 'domain')
|
|
31
|
+
domain(candidate, field.label);
|
|
32
|
+
if (field.treatment === 'hash' && !HEX_DIGEST_RE.test(candidate))
|
|
33
|
+
throw new TypeError('Storefront digests must be lowercase hexadecimal SHA-256.');
|
|
34
|
+
if (field.treatment === 'origin' && pageObservationOrigin(candidate) !== candidate)
|
|
35
|
+
throw new TypeError('Checkout observations retain HTTP(S) origins only.');
|
|
36
|
+
return candidate;
|
|
37
|
+
}))].sort();
|
|
38
|
+
}
|
|
39
|
+
return { hostname: row.hostname, observedAt: row.observedAt, source: text(row.source, 'Storefront source', 500), ...fields };
|
|
40
|
+
}
|
|
41
|
+
export function reviewStorefront(raw, reviewedAt) {
|
|
42
|
+
iso(reviewedAt, 'Review time');
|
|
43
|
+
const input = exact(raw, ['official', 'candidate', 'authorisedComparator', 'resellerStatus', 'resellerSource'], 'Storefront comparison');
|
|
44
|
+
boolean(input.authorisedComparator, 'Comparator authority');
|
|
45
|
+
if (!input.authorisedComparator)
|
|
46
|
+
throw new TypeError('Confirm that the official comparator is owned or authorised for this review.');
|
|
47
|
+
const official = readStorefrontObservation(input.official), candidate = readStorefrontObservation(input.candidate);
|
|
48
|
+
if (official.hostname === candidate.hostname)
|
|
49
|
+
throw new TypeError('Select a different candidate hostname for storefront comparison.');
|
|
50
|
+
const reseller = enumeration(input.resellerStatus, ['authorised', 'not_authorised', 'unknown'], 'Reseller status');
|
|
51
|
+
const resellerSource = input.resellerSource === null ? null : text(input.resellerSource, 'Reseller evidence', 500);
|
|
52
|
+
if (reseller !== 'unknown' && !resellerSource)
|
|
53
|
+
throw new TypeError('A reseller-status declaration needs its source or authority reference.');
|
|
54
|
+
const observations = [];
|
|
55
|
+
let missing = 0;
|
|
56
|
+
for (const field of STOREFRONT_FIELDS) {
|
|
57
|
+
const left = official[field.id], right = candidate[field.id];
|
|
58
|
+
if (left === null || right === null)
|
|
59
|
+
missing++;
|
|
60
|
+
const shared = left && right ? right.filter(value => left.includes(value)) : [];
|
|
61
|
+
observations.push({ label: field.label, state: left === null || right === null ? 'unknown' : 'observed',
|
|
62
|
+
detail: `Official: ${left === null ? 'not reviewed' : left.length ? left.join(', ') : 'no values recorded'}. Candidate: ${right === null ? 'not reviewed' : right.length ? right.join(', ') : 'no values recorded'}. ${left !== null && right !== null ? `Exact shared values: ${shared.length}.` : 'No comparison established.'}`,
|
|
63
|
+
source: `${official.hostname} · ${official.source} · ${official.observedAt} / ${candidate.hostname} · ${candidate.source}`, observedAt: candidate.observedAt, hostname: candidate.hostname });
|
|
64
|
+
}
|
|
65
|
+
observations.push({ label: 'Reseller or affiliate authority', state: reseller === 'unknown' ? 'unknown' : 'reported', detail: reseller.replaceAll('_', ' '), source: resellerSource ?? 'No authority evidence supplied', observedAt: null, hostname: candidate.hostname });
|
|
66
|
+
return { schema: CONTEXT_REVIEW_SCHEMA, version: CONTEXT_REVIEW_VERSION, kind: 'storefront', reviewedAt, title: 'Storefront comparison', state: missing ? 'partial' : 'reviewed',
|
|
67
|
+
summary: `${candidate.hostname} compared with the authorised reference ${official.hostname}; evidence categories missing one side: ${missing}.`, observations,
|
|
68
|
+
nextSteps: ['Check reseller, affiliate and regional-store authority before preparing a complaint.', 'Retain the source pages and distinctive assets privately; select relevant findings in the Case response packet.', 'Use the observed checkout platform and the rights or policy evidence to choose a reporting route. Do not enter payment information or submit an order.'],
|
|
69
|
+
limitations: ['This compares analyst-supplied observations. It does not collect a page, verify a merchant, submit a payment or issue a scam score.', 'Shared wording, assets and payment providers can have legitimate explanations. Digests establish byte equality only when inputs were captured consistently; missing observations do not establish absence.'] };
|
|
70
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Pure Brand Profile normalization and storage model. The browser wrapper owns
|
|
2
2
|
// persistence and downloads; this module owns schema migration, semantic field
|
|
3
3
|
// bounds, import merging, and exact serialized-byte accounting.
|
|
4
|
-
import { normalizeDomain } from "../
|
|
4
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
5
5
|
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
6
6
|
import { canonicalDomainControlRecords, normalizeDeclaredDomainControlRecordModes, normalizeDomainControlRecordModes } from "../evidence/domain-control-runtime.mjs";
|
|
7
7
|
import { DOMAIN_CONTROL_RECORD_LIST_FIELDS } from "../contracts/domain-control-manifest.mjs";
|
|
@@ -10,7 +10,7 @@ import { MAX_IDENTITY_DIGEST_BYTES, sha256IdentityHex } from "../evidence/record
|
|
|
10
10
|
import { DOMAIN_POSTURE_COMPARISON_VERSION, MAX_POSTURE_CHECKS, MAX_POSTURE_CHECK_RECORDS, MAX_POSTURE_RECORD_LENGTH, normalizeDomainPostureSourceContext, normalizeDomainPostureProfileContext } from "../evidence/domain-posture-context.mjs";
|
|
11
11
|
import { normalizeOpaqueReferenceId } from "../cases/opaque-reference-id.mjs";
|
|
12
12
|
import { normalizePageBaseline } from "./page-baseline.mjs";
|
|
13
|
-
import { isInformativePerceptualHash as isInformativeFaviconHash } from "
|
|
13
|
+
import { isInformativePerceptualHash as isInformativeFaviconHash } from "../analysis/perceptual-hash-comparison.mjs";
|
|
14
14
|
import { assertWorkspaceDeclaredVersion, assertWorkspaceInputGraph, assertWorkspacePortableVersion, ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
15
15
|
import { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
16
16
|
export { BRAND_PROFILE_SCHEMA, BRAND_PROFILE_SCHEMA_VERSION, MAX_DESIRED_POSTURE_BASELINES, MAX_DESIRED_POSTURE_CHANGE_WINDOWS, MAX_DESIRED_POSTURE_OBSERVATIONS, MAX_DESIRED_POSTURE_RECORDS, MAX_DESIRED_POSTURE_SUPPRESSIONS, MAX_DKIM_SELECTOR_LENGTH, MAX_DKIM_SELECTORS, MAX_OFFICIAL_CHANNELS, MAX_PROFILES, MAX_PROFILE_DOMAIN_LENGTH, MAX_PROFILE_NAME_LENGTH, MAX_PROFILE_STORE_BYTES, MAX_PROFILE_TEXT_LENGTH, MAX_PROFILE_TLD_LENGTH, MAX_PROFILE_URL_LENGTH, MAX_PROFILE_VALUE_INPUTS, MAX_PROFILE_VALUES, MAX_PROTECTION_ATTESTATIONS, MAX_RIGHTS_REFERENCES, SUPPORTED_BRAND_PROFILE_SCHEMA_VERSIONS, } from "../contracts/workspace-portability.mjs";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { normalizeDomain } from "../
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
2
2
|
import { CASE_DISPOSITIONS as CASE_DISPOSITION_OPTIONS } from "../cases/case-record-contracts.mjs";
|
|
3
3
|
import { BULK_SORT_KEYS, normalizeBulkPresentationSortKey } from "./bulk-sort.mjs";
|
|
4
4
|
import { normalizeBulkResultColumns } from "./bulk-columns.mjs";
|
|
@@ -235,37 +235,38 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
|
|
|
235
235
|
const rows = new Map(local.rows.map((item) => [item.domain, item]));
|
|
236
236
|
let added = 0;
|
|
237
237
|
let updated = 0;
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
updated += 1;
|
|
248
|
-
}
|
|
249
|
-
else {
|
|
238
|
+
const supplied = sourceLists(importedRaw);
|
|
239
|
+
let skipped = supplied.presets.length + supplied.rows.length - imported.presets.length - imported.rows.length;
|
|
240
|
+
let capacitySkipped = 0;
|
|
241
|
+
let bytes = byteLength(JSON.stringify(local));
|
|
242
|
+
if (bytes > MAX_BULK_REVIEW_STORE_BYTES)
|
|
243
|
+
throw new Error('Existing Bulk review preferences exceed the workspace storage limit; no imported changes were applied.');
|
|
244
|
+
function admit(target, key, candidate, maximum) {
|
|
245
|
+
const existing = target.get(key);
|
|
246
|
+
if (existing && candidate.updatedAt <= existing.updatedAt) {
|
|
250
247
|
skipped += 1;
|
|
248
|
+
return;
|
|
251
249
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (!existing) {
|
|
256
|
-
rows.set(candidate.domain, candidate);
|
|
257
|
-
added += 1;
|
|
258
|
-
}
|
|
259
|
-
else if (candidate.updatedAt > existing.updatedAt) {
|
|
260
|
-
rows.set(candidate.domain, candidate);
|
|
261
|
-
updated += 1;
|
|
262
|
-
}
|
|
263
|
-
else {
|
|
250
|
+
const delta = byteLength(JSON.stringify(candidate)) - (existing ? byteLength(JSON.stringify(existing)) : 0)
|
|
251
|
+
+ (!existing && target.size ? 1 : 0);
|
|
252
|
+
if ((!existing && target.size >= maximum) || bytes + delta > MAX_BULK_REVIEW_STORE_BYTES) {
|
|
264
253
|
skipped += 1;
|
|
254
|
+
capacitySkipped += 1;
|
|
255
|
+
return;
|
|
265
256
|
}
|
|
257
|
+
target.set(key, candidate);
|
|
258
|
+
bytes += delta;
|
|
259
|
+
if (existing)
|
|
260
|
+
updated += 1;
|
|
261
|
+
else
|
|
262
|
+
added += 1;
|
|
266
263
|
}
|
|
264
|
+
for (const candidate of imported.presets)
|
|
265
|
+
admit(presets, candidate.id, candidate, MAX_BULK_REVIEW_PRESETS);
|
|
266
|
+
for (const candidate of imported.rows)
|
|
267
|
+
admit(rows, candidate.domain, candidate, MAX_BULK_REVIEW_ROWS);
|
|
267
268
|
return {
|
|
268
|
-
store:
|
|
269
|
+
store: normalizeBulkReviewStore({
|
|
269
270
|
schema: BULK_REVIEW_SCHEMA,
|
|
270
271
|
version: BULK_REVIEW_SCHEMA_VERSION,
|
|
271
272
|
presets: [...presets.values()],
|
|
@@ -274,5 +275,6 @@ export function mergeBulkReviewStores(localRaw, importedRaw) {
|
|
|
274
275
|
added,
|
|
275
276
|
updated,
|
|
276
277
|
skipped,
|
|
278
|
+
reason: capacitySkipped ? `${capacitySkipped} imported Bulk review records did not fit the remaining storage capacity. Existing local records are preserved.` : '',
|
|
277
279
|
};
|
|
278
280
|
}
|