@slicedearth/whoisleuth-cli 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +6 -0
- package/README.md +20 -15
- package/bin/whoisleuth.mjs +8 -1
- package/cli/archive-content-digest.mjs +14 -0
- package/cli/archive-inspect.mjs +20 -53
- package/cli/arguments.mjs +454 -1748
- package/cli/artifact-validation/case-response.mjs +66 -35
- package/cli/artifact-validation/investigation-capsule.mjs +52 -31
- package/cli/artifact-validation/lookup-evidence.mjs +12 -6
- package/cli/artifact-validation/signed-review.mjs +112 -72
- package/cli/artifact-validation/structure-primitives.mjs +1 -105
- package/cli/artifact-verify.mjs +90 -24
- package/cli/assurance-command-runner.mjs +39 -4
- package/cli/bagit-folder.mjs +57 -0
- package/cli/bagit-review.mjs +23 -0
- package/cli/bounded-json.mjs +1 -1
- package/cli/bulk-checkpoint.mjs +34 -3
- package/cli/bulk-command-runner.mjs +2 -0
- package/cli/bulk-output.mjs +29 -3
- package/cli/case-command.mjs +195 -0
- package/cli/case-pack.mjs +66 -111
- package/cli/command-argument-grammar.mjs +215 -0
- package/cli/command-reference.mjs +342 -285
- package/cli/completion.mjs +303 -80
- package/cli/config-profile.mjs +7 -5
- package/cli/ct-event-intake.mjs +12 -12
- package/cli/discover.mjs +4 -3
- package/cli/discovery-observation-snapshot.mjs +59 -18
- package/cli/discovery-scan-command-runner.mjs +21 -0
- package/cli/discovery-scan.mjs +13 -4
- package/cli/discovery-workflow.mjs +1 -1
- package/cli/domain-control-monitor.mjs +50 -20
- package/cli/domain-control-observations.mjs +149 -102
- package/cli/errors.mjs +33 -1
- package/cli/evidence-command-runner.mjs +16 -5
- package/cli/evidence-signing.mjs +2 -2
- package/cli/external-observation-mapping.mjs +7 -8
- package/cli/formatters/terminal-lookup.mjs +18 -46
- package/cli/formatters/terminal-shared.mjs +5 -3
- package/cli/formatters/terminal-technology.mjs +114 -0
- package/cli/inline-command-families.mjs +2 -0
- package/cli/input.mjs +116 -0
- package/cli/interactive-launcher.mjs +2 -160
- package/cli/interchange-report.mjs +18 -8
- package/cli/investigation-artifacts.mjs +176 -0
- package/cli/investigation-checkpoint.mjs +10 -0
- package/cli/investigation-folder.mjs +114 -0
- package/cli/investigation-manifest.mjs +1 -112
- package/cli/investigation-package-review.mjs +103 -0
- package/cli/investigation-plan.mjs +32 -19
- package/cli/investigation-run.mjs +188 -33
- package/cli/local-document-checkpoint.mjs +170 -0
- package/cli/lookalike-calibration.mjs +7 -2
- package/cli/lookup-brief.mjs +4 -2
- package/cli/lookup-browser-view.mjs +1 -1
- package/cli/lookup-browser.mjs +5 -12
- package/cli/lookup-command-runner.mjs +17 -6
- package/cli/lookup-diff.mjs +37 -3
- package/cli/lookup-plan.mjs +11 -5
- package/cli/lookup-reconcile.mjs +10 -8
- package/cli/mail-header-review.mjs +467 -0
- package/cli/mail-review.mjs +66 -14
- package/cli/manual.mjs +1 -0
- package/cli/network-command-runner.mjs +2 -0
- package/cli/output-file.mjs +31 -5
- package/cli/registry-doctor.mjs +3 -1
- package/cli/registry-support.mjs +1 -1
- package/cli/retained-artifact-diff.mjs +2 -2
- package/cli/review-command-runner.mjs +70 -0
- package/cli/risk-calibration.mjs +19 -17
- package/cli/runner.mjs +62 -7
- package/cli/sharing-review.mjs +21 -34
- package/cli/signer-trust.mjs +104 -0
- package/cli/source-reliability.mjs +74 -83
- package/cli/terminal-input.mjs +168 -0
- package/cli/workflow-command-runner.mjs +68 -13
- package/docs/cli-reference.md +77 -24
- package/docs/cli.md +300 -25
- package/lib/abort.mjs +4 -1
- package/lib/availability.mjs +161 -112
- package/lib/bounded-file.mjs +3 -1
- package/lib/bounded-html-document.mjs +125 -0
- package/lib/bounded-json.mjs +51 -8
- package/lib/browser-library-profile.mjs +42 -51
- package/lib/browser-library-worker.mjs +112 -0
- package/lib/client-behavior-profile.mjs +3 -3
- package/lib/ct-search.mjs +11 -4
- package/lib/dns-convergence-review.mjs +1 -0
- package/lib/dns-delegation-health.mjs +24 -14
- package/lib/dns-inheritance-review.mjs +217 -0
- package/lib/dns-intelligence.mjs +73 -26
- package/lib/dns-mx.mjs +1 -1
- package/lib/dnssec-chain-validation.mjs +6 -0
- package/lib/dnssec-evidence-validation.mjs +11 -3
- package/lib/domain-change-packet.mjs +49 -16
- package/lib/domain-change-review.mjs +14 -8
- package/lib/domain-control-flight-recorder.mjs +221 -117
- package/lib/domain-control-manifest.mjs +129 -69
- package/lib/domain-posture-analysis.mjs +20 -7
- package/lib/domain-posture-parsers.mjs +201 -8
- package/lib/domain-posture.mjs +147 -29
- package/lib/evidence-export-privacy.mjs +561 -0
- package/lib/evidence-export.mjs +11 -543
- package/lib/evidence-report.mjs +5 -3
- package/lib/favicon.mjs +117 -41
- package/lib/generated/cisa-kev-catalog.mjs +12 -4
- package/lib/generated/registrar-standing-catalogue.mjs +7 -7
- package/lib/generated/retire-browser-catalog.mjs +2335 -940
- package/lib/hostname.mjs +2 -38
- package/lib/html-signals.mjs +111 -108
- package/lib/http-evidence-bounds.mjs +2 -1
- package/lib/http-intelligence.mjs +39 -26
- package/lib/local-geoip-evidence.mjs +19 -3
- package/lib/lookup-cache.mjs +34 -11
- package/lib/lookup-child-profile-contract.mjs +24 -15
- package/lib/lookup-diagnostics.mjs +24 -1
- package/lib/lookup-progress.mjs +30 -13
- package/lib/lookup-response-contract.mjs +1048 -0
- package/lib/lookup-response.mjs +8 -10
- package/lib/lookup-source-progress.mjs +40 -14
- package/lib/lookup.mjs +48 -27
- package/lib/nameserver-preflight-review.mjs +1 -0
- package/lib/observed-network-context.mjs +43 -27
- package/lib/outbound-request-bounds.mjs +4 -1
- package/lib/page-fingerprints.mjs +69 -133
- package/lib/page-role-profile.mjs +12 -8
- package/lib/perceptual-hash.mjs +43 -14
- package/lib/rdap-bootstrap.mjs +72 -22
- package/lib/rdap-capabilities.mjs +1 -1
- package/lib/rdap-client.mjs +15 -7
- package/lib/rdap-normalization.mjs +33 -18
- package/lib/rdap-search-workbench.mjs +1 -1
- package/lib/rdap-transport.mjs +47 -20
- package/lib/rdap-types.mjs +2 -1
- package/lib/rdap.mjs +18 -3
- package/lib/registrable-domain.mjs +21 -1
- package/lib/registrar-standing-contract.mjs +1 -0
- package/lib/registry-capabilities.mjs +5 -1
- package/lib/registry-capability-catalogue.mjs +2 -1
- package/lib/registry-dates.mjs +2 -190
- package/lib/registry-insights.mjs +85 -34
- package/lib/response-policy.mjs +86 -48
- package/lib/risk-calibration-summary.mjs +18 -6
- package/lib/rpki-evidence.mjs +8 -5
- package/lib/safe-fetch.mjs +54 -47
- package/lib/security-txt.mjs +42 -7
- package/lib/service-binding-dns.mjs +8 -0
- package/lib/smtp-transport-review.mjs +27 -15
- package/lib/sslbl-certificates.generated.mjs +136 -135
- package/lib/static-html-analysis.mjs +178 -151
- package/lib/structured-data-identity.mjs +1 -0
- package/lib/technology-markup.mjs +100 -0
- package/lib/threat-intelligence-manifests.mjs +11 -139
- package/lib/threat-intelligence-runtime.mjs +24 -52
- package/lib/threat-intelligence-targets.mjs +6 -34
- package/lib/threat-intelligence-types.mjs +39 -0
- package/lib/typosquat-generator.mjs +19 -15
- package/lib/urlhaus-intelligence.mjs +8 -3
- package/lib/urlscan-intelligence.mjs +1 -1
- package/lib/website-security-posture.mjs +30 -6
- package/lib/website-technology.mjs +153 -45
- package/lib/whois-authority.mjs +14 -2
- package/lib/whois-chain.mjs +9 -2
- package/lib/whois-common-formats.mjs +5 -5
- package/lib/whois-contracts.mjs +2 -1
- package/lib/whois-dialects.mjs +2 -2
- package/lib/whois-parser-support.mjs +8 -1
- package/lib/whois-transport.mjs +23 -7
- package/lib/zone-intent-review.mjs +16 -6
- package/package.json +2 -1
- package/packages/cases/case-attachment-model.mjs +83 -0
- package/packages/cases/case-evidence-model.mjs +156 -62
- package/packages/cases/case-export-input.mjs +53 -0
- package/packages/cases/case-migration-model.mjs +127 -44
- package/packages/cases/case-model.mjs +1 -0
- package/packages/cases/case-recheck-model.mjs +91 -0
- package/packages/cases/case-record-contracts.mjs +5 -20
- package/packages/cases/case-record-core.mjs +12 -22
- package/packages/cases/case-record-decisions.mjs +72 -0
- package/packages/cases/case-record-model.mjs +1 -0
- package/packages/cases/case-record-operations.mjs +95 -31
- package/packages/cases/case-record-projection.mjs +221 -0
- package/packages/cases/case-report.mjs +70 -54
- package/packages/cases/case-response-actions.mjs +654 -0
- package/packages/cases/case-response-model.mjs +64 -1115
- package/packages/cases/case-response-outcomes.mjs +317 -0
- package/packages/cases/case-response-packet-render.mjs +153 -0
- package/packages/cases/case-response-packet-types.mjs +1 -0
- package/packages/cases/case-response-packet-vocabulary.mjs +48 -0
- package/packages/cases/case-response-packet.mjs +74 -572
- package/packages/cases/case-response-records.mjs +94 -0
- package/packages/cases/case-response-review-inputs.mjs +371 -0
- package/packages/cases/case-response-values.mjs +85 -0
- package/packages/cases/case-review-package.mjs +45 -0
- package/packages/cases/case-selection.mjs +46 -0
- package/packages/cases/case-storage-model.mjs +31 -2
- package/packages/cases/response-route-freshness.mjs +16 -0
- package/packages/comparison/capture-context.mjs +61 -0
- package/packages/comparison/comparison-ledger-bulk.mjs +13 -11
- package/packages/comparison/page-similarity.mjs +5 -2
- package/packages/comparison/relationship-evidence.mjs +85 -8
- package/packages/comparison/relationship-provenance.mjs +93 -0
- package/packages/contracts/analyst-interchange.mjs +216 -0
- package/packages/contracts/analyst-review-state-contract.mjs +11 -0
- package/packages/contracts/analyst-review-state.mjs +5 -4
- package/packages/contracts/case-portability.mjs +366 -83
- package/packages/contracts/case-views-contract.mjs +12 -0
- package/packages/contracts/case-views.mjs +26 -0
- package/packages/contracts/cli-command-semantics.mjs +94 -0
- package/packages/contracts/cli-lookup.mjs +0 -1
- package/packages/contracts/domain-control-flight-recorder.mjs +80 -42
- package/packages/contracts/domain-control-manifest.mjs +127 -73
- package/packages/contracts/domain-control-monitor.mjs +48 -45
- package/packages/contracts/domain-control-review.mjs +179 -121
- package/packages/contracts/domain-name.mjs +21 -0
- package/packages/contracts/external-observation-interchange.mjs +2 -1
- package/packages/contracts/extracted-domain-lifecycle.mjs +11 -32
- package/packages/contracts/http-url.mjs +2 -0
- package/packages/contracts/investigation-package-limits.mjs +24 -0
- package/packages/contracts/investigation-portability.mjs +56 -27
- package/packages/contracts/investigation-run.mjs +16 -0
- package/packages/contracts/ip-address.mjs +34 -0
- package/packages/contracts/lookup-evidence.mjs +35 -0
- package/packages/contracts/offline-comparison.mjs +1 -2
- package/packages/contracts/page-fingerprints.mjs +15 -0
- package/packages/contracts/risk-calibration.mjs +15 -11
- package/packages/contracts/schema-compatibility.mjs +1 -1
- package/packages/contracts/schema-lifecycle.mjs +83 -153
- package/packages/contracts/selected-file-limits.mjs +6 -0
- package/packages/contracts/vulnerability-identifiers.mjs +5 -0
- package/packages/contracts/web-capture.mjs +10 -0
- package/packages/contracts/workspace-portability.mjs +183 -211
- package/packages/evidence/artifact-integrity.mjs +8 -2
- package/packages/evidence/artifact-structure.mjs +105 -0
- package/packages/evidence/decision-fact.mjs +4 -1
- package/packages/evidence/domain-control-runtime.mjs +201 -33
- package/packages/evidence/domain-posture-context.mjs +79 -0
- package/packages/evidence/image-regions.mjs +56 -0
- package/packages/evidence/latest-observations.mjs +28 -0
- package/packages/evidence/lookup-target.mjs +104 -0
- package/packages/evidence/observation.mjs +16 -1
- package/packages/evidence/passphrase-encryption.mjs +44 -0
- package/packages/evidence/record-identity.mjs +72 -0
- package/packages/evidence/registry-dates.mjs +190 -0
- package/packages/evidence/retained-file.mjs +69 -0
- package/packages/evidence/review-age.mjs +14 -0
- package/packages/interchange/bagit.mjs +342 -0
- package/packages/interchange/bounded-zip-extraction.mjs +278 -0
- package/packages/interchange/crc32.mjs +17 -0
- package/packages/interchange/external-findings-import.mjs +92 -51
- package/packages/interchange/web-capture-import.mjs +382 -0
- package/packages/investigation/bulk-mail-exposure.mjs +2 -1
- package/packages/investigation/investigation-bagit.mjs +14 -0
- package/packages/investigation/investigation-capsule.mjs +33 -5
- package/packages/investigation/investigation-manifest.mjs +200 -0
- package/packages/investigation/investigation-package-crypto.mjs +98 -0
- package/packages/investigation/investigation-package.mjs +200 -0
- package/packages/investigation/lookup-asset-graph-inputs.mjs +38 -0
- package/packages/investigation/lookup-asset-graph.mjs +193 -165
- package/packages/investigation/lookup-claim-readiness.mjs +18 -0
- package/packages/investigation/lookup-investigation-brief.mjs +3 -3
- package/packages/investigation/lookup-task-guidance.mjs +58 -0
- package/packages/monitoring/analyst-review-state.mjs +38 -90
- package/packages/workspace/brand-profile-model.mjs +276 -22
- package/packages/workspace/bulk-columns.mjs +17 -0
- package/packages/workspace/bulk-review-model.mjs +23 -11
- package/packages/workspace/bulk-session-model.mjs +100 -24
- package/packages/workspace/bulk-sort.mjs +1 -0
- package/packages/workspace/case-views.mjs +102 -0
- package/packages/workspace/detection-rule-model.mjs +70 -22
- package/packages/workspace/hostile-input.mjs +29 -8
- package/packages/workspace/investigation-template-model.mjs +19 -4
- package/packages/workspace/page-baseline.mjs +18 -15
- package/packages/workspace/relationship-observation-model.mjs +41 -23
- package/packages/workspace/watchlist-history.mjs +2 -2
- package/packages/workspace/watchlist-store.mjs +8 -2
- package/packages/workspace/website-snapshot-model.mjs +164 -26
- package/packages/workspace/workspace-archive-crypto.mjs +41 -61
- package/packages/workspace/workspace-archive.mjs +87 -21
- package/third-party-notices.txt +29 -2
- package/cli/evidence-command-arguments.mjs +0 -156
- package/packages/investigation/bulk-review-export.mjs +0 -63
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// those intermediate representations exist only long enough to derive a digest.
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { analyzeStaticHtml } from "./static-html-analysis.mjs";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
import { PAGE_FINGERPRINT_VERSION, PAGE_FINGERPRINT_PARSERS, PAGE_FINGERPRINT_TOKEN_LIMITS, } from "../packages/contracts/page-fingerprints.mjs";
|
|
7
|
+
import { MAX_HOMEPAGE_BYTES } from "./outbound-request-bounds.mjs";
|
|
8
|
+
const MAX_FINGERPRINT_SOURCE_BYTES = MAX_HOMEPAGE_BYTES;
|
|
8
9
|
const MAX_FINGERPRINT_TAG_LENGTH = 4096;
|
|
9
|
-
const MAX_FINGERPRINT_TOKENS =
|
|
10
|
+
const MAX_FINGERPRINT_TOKENS = PAGE_FINGERPRINT_TOKEN_LIMITS[PAGE_FINGERPRINT_VERSION];
|
|
10
11
|
const MAX_FINGERPRINT_ATTRIBUTES = 64;
|
|
11
12
|
const MAX_VISIBLE_TEXT_TOKENS = 8192;
|
|
12
13
|
const MAX_FORM_FINGERPRINTS = 50;
|
|
@@ -15,11 +16,6 @@ const MAX_RESOURCE_HOSTS = 30;
|
|
|
15
16
|
const MAX_IDENTIFIER_VALUES = 30;
|
|
16
17
|
const CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/gu;
|
|
17
18
|
const HAS_CONTROL_RE = /[\u0000-\u001f\u007f-\u009f]|\p{Default_Ignorable_Code_Point}/u;
|
|
18
|
-
const COMMENT_RE = /<!--[\s\S]*?(?:-->|$)/g;
|
|
19
|
-
const RAW_BODY_RE = /<(script|style|noscript|textarea|template)\b([^>]*)>[\s\S]*?(?:<\/\1\s*>|$)/gi;
|
|
20
|
-
const MARKUP_TOKEN_RE = /<\/?[A-Za-z][A-Za-z0-9:-]*\b[^>]*>|[^<]+/g;
|
|
21
|
-
const TAG_NAME_RE = /^<\s*(\/?)\s*([A-Za-z][A-Za-z0-9:-]*)/;
|
|
22
|
-
const ATTRIBUTE_RE = /([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
23
19
|
const WORD_RE = /[\p{L}\p{N}]+/gu;
|
|
24
20
|
const URL_ATTRIBUTE_NAMES = new Set(['action', 'cite', 'data', 'formaction', 'href', 'poster', 'src']);
|
|
25
21
|
const OMITTED_ATTRIBUTE_NAMES = new Set(['integrity', 'nonce', 'srcdoc', 'style', 'value']);
|
|
@@ -28,16 +24,12 @@ function sha256(value) {
|
|
|
28
24
|
return createHash('sha256').update(value).digest('hex');
|
|
29
25
|
}
|
|
30
26
|
function boundedSource(html) {
|
|
31
|
-
const
|
|
32
|
-
const
|
|
33
|
-
const
|
|
27
|
+
const supplied = typeof html === 'string' ? html : '';
|
|
28
|
+
const prefix = Buffer.from(supplied.slice(0, MAX_FINGERPRINT_SOURCE_BYTES), 'utf8');
|
|
29
|
+
const truncated = supplied.length > MAX_FINGERPRINT_SOURCE_BYTES || prefix.length > MAX_FINGERPRINT_SOURCE_BYTES;
|
|
30
|
+
const bytes = prefix.subarray(0, MAX_FINGERPRINT_SOURCE_BYTES);
|
|
34
31
|
return { bytes, text: bytes.toString('utf8'), truncated };
|
|
35
32
|
}
|
|
36
|
-
function staticMarkup(html) {
|
|
37
|
-
return html
|
|
38
|
-
.replace(COMMENT_RE, ' ')
|
|
39
|
-
.replace(RAW_BODY_RE, '<$1$2></$1>');
|
|
40
|
-
}
|
|
41
33
|
function normalizedExactBodyHash(value) {
|
|
42
34
|
if (!value || typeof value !== 'object')
|
|
43
35
|
return null;
|
|
@@ -56,39 +48,6 @@ function normalizedExactBodyHash(value) {
|
|
|
56
48
|
source: 'captured-response-bytes',
|
|
57
49
|
};
|
|
58
50
|
}
|
|
59
|
-
function parseAttributes(tag) {
|
|
60
|
-
const start = tag.search(/\s/);
|
|
61
|
-
if (start === -1)
|
|
62
|
-
return { values: new Map(), truncated: false };
|
|
63
|
-
const values = new Map();
|
|
64
|
-
let truncated = false;
|
|
65
|
-
let match;
|
|
66
|
-
ATTRIBUTE_RE.lastIndex = 0;
|
|
67
|
-
while ((match = ATTRIBUTE_RE.exec(tag.slice(start)))) {
|
|
68
|
-
const name = match[1]?.toLowerCase();
|
|
69
|
-
if (!name)
|
|
70
|
-
continue;
|
|
71
|
-
if (values.has(name))
|
|
72
|
-
continue;
|
|
73
|
-
if (values.size >= MAX_FINGERPRINT_ATTRIBUTES) {
|
|
74
|
-
truncated = true;
|
|
75
|
-
break;
|
|
76
|
-
}
|
|
77
|
-
values.set(name, match[2] ?? match[3] ?? match[4] ?? '');
|
|
78
|
-
}
|
|
79
|
-
return { values, truncated };
|
|
80
|
-
}
|
|
81
|
-
function decodeStableEntities(text) {
|
|
82
|
-
const named = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
|
|
83
|
-
return text.replace(/&(?:#(\d{1,7})|#x([a-f0-9]{1,6})|([a-z]{2,8}));/gi, (whole, decimal, hex, name) => {
|
|
84
|
-
if (name)
|
|
85
|
-
return named[name.toLowerCase()] ?? whole.toLowerCase();
|
|
86
|
-
const point = Number.parseInt(decimal || hex, decimal ? 10 : 16);
|
|
87
|
-
if (!Number.isInteger(point) || point <= 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff))
|
|
88
|
-
return ' ';
|
|
89
|
-
return String.fromCodePoint(point);
|
|
90
|
-
});
|
|
91
|
-
}
|
|
92
51
|
function looksRandom(value) {
|
|
93
52
|
if (typeof value !== 'string')
|
|
94
53
|
return false;
|
|
@@ -108,7 +67,7 @@ function reduceDynamicValues(value) {
|
|
|
108
67
|
.replace(/\b(?=[a-z0-9_-]{20,}\b)(?=[a-z0-9_-]*[a-z])(?=[a-z0-9_-]*\d)[a-z0-9_-]+\b/gi, '<id>');
|
|
109
68
|
}
|
|
110
69
|
function normalizeText(value, maxLength = 512) {
|
|
111
|
-
const normalized = reduceDynamicValues(
|
|
70
|
+
const normalized = reduceDynamicValues(String(value || ''))
|
|
112
71
|
.normalize('NFKC')
|
|
113
72
|
.replace(CONTROL_RE, ' ')
|
|
114
73
|
.replace(/\s+/g, ' ')
|
|
@@ -150,49 +109,33 @@ function normalizeAttribute(name, value, baseUrl) {
|
|
|
150
109
|
return name;
|
|
151
110
|
return normalized ? `${name}=${normalized}` : name;
|
|
152
111
|
}
|
|
153
|
-
function normalizedMarkupAndStructure(
|
|
154
|
-
const markup = staticMarkup(html);
|
|
112
|
+
function normalizedMarkupAndStructure(analysis, baseUrl) {
|
|
155
113
|
const normalizedTokens = [];
|
|
156
|
-
const structureTokens =
|
|
114
|
+
const structureTokens = analysis.structureTokens;
|
|
157
115
|
const visibleSegments = [];
|
|
158
|
-
let normalizedTruncated =
|
|
159
|
-
|
|
116
|
+
let normalizedTruncated = analysis.structureLimitReached;
|
|
117
|
+
const structureTruncated = analysis.structureLimitReached;
|
|
160
118
|
let attributesTruncated = false;
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
while ((match = MARKUP_TOKEN_RE.exec(markup))) {
|
|
165
|
-
const token = match[0];
|
|
166
|
-
if (token.startsWith('<')) {
|
|
167
|
-
if (token.length > MAX_FINGERPRINT_TAG_LENGTH) {
|
|
168
|
-
oversizedTag = true;
|
|
169
|
-
continue;
|
|
170
|
-
}
|
|
171
|
-
const nameMatch = token.match(TAG_NAME_RE);
|
|
172
|
-
const name = nameMatch?.[2]?.toLowerCase();
|
|
173
|
-
if (!nameMatch || !name)
|
|
174
|
-
continue;
|
|
175
|
-
const closing = Boolean(nameMatch[1]);
|
|
176
|
-
const selfClosing = !closing && (VOID_TAGS.has(name) || /\/\s*>$/.test(token));
|
|
177
|
-
const structure = closing ? `/${name}` : selfClosing ? `${name}/` : name;
|
|
178
|
-
if (structureTokens.length < MAX_FINGERPRINT_TOKENS)
|
|
179
|
-
structureTokens.push(structure);
|
|
180
|
-
else
|
|
181
|
-
structureTruncated = true;
|
|
119
|
+
const oversizedTag = analysis.elements.some((element) => element.attributesTruncated);
|
|
120
|
+
for (const token of analysis.tokens) {
|
|
121
|
+
if (token.kind !== 'text') {
|
|
182
122
|
if (normalizedTokens.length >= MAX_FINGERPRINT_TOKENS) {
|
|
183
123
|
normalizedTruncated = true;
|
|
184
124
|
continue;
|
|
185
125
|
}
|
|
186
|
-
if (
|
|
187
|
-
normalizedTokens.push(`</${name}>`);
|
|
126
|
+
if (token.kind === 'end') {
|
|
127
|
+
normalizedTokens.push(`</${token.name}>`);
|
|
188
128
|
continue;
|
|
189
129
|
}
|
|
190
|
-
const
|
|
191
|
-
|
|
130
|
+
const element = token.element;
|
|
131
|
+
const name = element.name;
|
|
132
|
+
const selfClosing = element.html && VOID_TAGS.has(name);
|
|
133
|
+
if (element.attributesTruncated || element.attributes.length > MAX_FINGERPRINT_ATTRIBUTES)
|
|
192
134
|
attributesTruncated = true;
|
|
193
|
-
const
|
|
135
|
+
const values = new Map(element.attributes.slice(0, MAX_FINGERPRINT_ATTRIBUTES).map(({ name, value }) => [name, value]));
|
|
136
|
+
const attributes = [...values.entries()]
|
|
194
137
|
.map(([attributeName, value]) => {
|
|
195
|
-
const metaKey = name === 'meta' ? String(
|
|
138
|
+
const metaKey = name === 'meta' ? String(values.get('name') || values.get('property') || '') : '';
|
|
196
139
|
if (attributeName === 'content' && /(?:csrf|nonce|session|token)/i.test(metaKey))
|
|
197
140
|
return null;
|
|
198
141
|
return normalizeAttribute(attributeName, value, baseUrl);
|
|
@@ -202,7 +145,7 @@ function normalizedMarkupAndStructure(html, baseUrl) {
|
|
|
202
145
|
normalizedTokens.push(`<${name}${attributes.length ? ` ${attributes.join(' ')}` : ''}${selfClosing ? '/' : ''}>`);
|
|
203
146
|
continue;
|
|
204
147
|
}
|
|
205
|
-
const text = normalizeText(token, MAX_FINGERPRINT_SOURCE_BYTES);
|
|
148
|
+
const text = normalizeText(token.value, MAX_FINGERPRINT_SOURCE_BYTES);
|
|
206
149
|
if (!text)
|
|
207
150
|
continue;
|
|
208
151
|
visibleSegments.push(text);
|
|
@@ -270,66 +213,55 @@ function formActionClass(value, baseUrl) {
|
|
|
270
213
|
return 'external-insecure';
|
|
271
214
|
return 'external';
|
|
272
215
|
}
|
|
273
|
-
function formStructureFingerprint(
|
|
274
|
-
const
|
|
275
|
-
|
|
216
|
+
function formStructureFingerprint(analysis, baseUrl) {
|
|
217
|
+
const formByElement = new Map();
|
|
218
|
+
const firstElementById = new Map();
|
|
276
219
|
let formLimitReached = false;
|
|
277
220
|
let controlLimitReached = false;
|
|
278
221
|
let controls = 0;
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
const name = nameMatch?.[2]?.toLowerCase();
|
|
287
|
-
if (!nameMatch || !name)
|
|
288
|
-
continue;
|
|
289
|
-
const closing = Boolean(nameMatch[1]);
|
|
290
|
-
if (name === 'form') {
|
|
291
|
-
if (closing) {
|
|
292
|
-
if (current)
|
|
293
|
-
forms.push(current);
|
|
294
|
-
current = null;
|
|
295
|
-
continue;
|
|
296
|
-
}
|
|
297
|
-
if (current)
|
|
298
|
-
forms.push(current);
|
|
299
|
-
if (forms.length >= MAX_FORM_FINGERPRINTS) {
|
|
222
|
+
for (const [index, element] of analysis.elements.entries()) {
|
|
223
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
224
|
+
const id = attributes.get('id');
|
|
225
|
+
if (id && !firstElementById.has(id))
|
|
226
|
+
firstElementById.set(id, index);
|
|
227
|
+
if (element.html && element.name === 'form') {
|
|
228
|
+
if (formByElement.size >= MAX_FORM_FINGERPRINTS) {
|
|
300
229
|
formLimitReached = true;
|
|
301
|
-
current = null;
|
|
302
230
|
continue;
|
|
303
231
|
}
|
|
304
|
-
const attributes = parseAttributes(token).values;
|
|
305
232
|
const rawMethod = normalizeText(attributes.get('method') || 'get', 20);
|
|
306
|
-
|
|
233
|
+
formByElement.set(index, {
|
|
307
234
|
method: /^[a-z]{1,20}$/.test(rawMethod) ? rawMethod : 'other',
|
|
308
235
|
action: formActionClass(attributes.get('action'), baseUrl),
|
|
309
236
|
controls: {},
|
|
310
|
-
};
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
for (const element of analysis.elements) {
|
|
241
|
+
const name = element.name;
|
|
242
|
+
if (!element.html || !['button', 'input', 'select', 'textarea'].includes(name))
|
|
311
243
|
continue;
|
|
244
|
+
const attributes = new Map(element.attributes.map(({ name, value }) => [name, value]));
|
|
245
|
+
let owner = attributes.has('form') ? firstElementById.get(attributes.get('form')) ?? null : element.parent;
|
|
246
|
+
if (!attributes.has('form')) {
|
|
247
|
+
while (owner !== null && !formByElement.has(owner))
|
|
248
|
+
owner = analysis.elements[owner]?.parent ?? null;
|
|
312
249
|
}
|
|
313
|
-
|
|
250
|
+
const current = owner === null ? undefined : formByElement.get(owner);
|
|
251
|
+
if (!current)
|
|
314
252
|
continue;
|
|
315
253
|
if (controls >= MAX_FORM_CONTROLS) {
|
|
316
254
|
controlLimitReached = true;
|
|
317
255
|
continue;
|
|
318
256
|
}
|
|
319
257
|
controls += 1;
|
|
320
|
-
const attributes = parseAttributes(token).values;
|
|
321
258
|
const rawType = name === 'input' || name === 'button'
|
|
322
259
|
? normalizeText(attributes.get('type') || (name === 'input' ? 'text' : 'submit'), 30)
|
|
323
260
|
: name;
|
|
324
261
|
const type = /^[a-z0-9-]{1,30}$/.test(rawType) ? `${name}:${rawType}` : `${name}:other`;
|
|
325
262
|
current.controls[type] = (current.controls[type] || 0) + 1;
|
|
326
263
|
}
|
|
327
|
-
|
|
328
|
-
forms.push(current);
|
|
329
|
-
if (forms.length > MAX_FORM_FINGERPRINTS) {
|
|
330
|
-
forms.length = MAX_FORM_FINGERPRINTS;
|
|
331
|
-
formLimitReached = true;
|
|
332
|
-
}
|
|
264
|
+
const forms = [...formByElement.values()];
|
|
333
265
|
for (const form of forms)
|
|
334
266
|
form.controls = Object.fromEntries(Object.entries(form.controls).sort(([left], [right]) => left.localeCompare(right)));
|
|
335
267
|
return forms.length ? {
|
|
@@ -337,7 +269,7 @@ function formStructureFingerprint(markup, baseUrl) {
|
|
|
337
269
|
value: sha256(JSON.stringify(forms)),
|
|
338
270
|
formCount: forms.length,
|
|
339
271
|
controlCount: controls,
|
|
340
|
-
truncated: formLimitReached || controlLimitReached,
|
|
272
|
+
truncated: formLimitReached || controlLimitReached || analysis.tagLimitReached || analysis.inputLimitReached,
|
|
341
273
|
} : null;
|
|
342
274
|
}
|
|
343
275
|
function normalizedResourceHosts(resources) {
|
|
@@ -418,21 +350,25 @@ function createPageFingerprints(html, options = {}) {
|
|
|
418
350
|
};
|
|
419
351
|
if (options.sourceTruncated === true && exact.scope === 'complete-body')
|
|
420
352
|
exact = { ...exact, scope: 'captured-prefix' };
|
|
421
|
-
const
|
|
422
|
-
|
|
353
|
+
const parsedStructure = !source.truncated && options.htmlAnalysis
|
|
354
|
+
? options.htmlAnalysis : analyzeStaticHtml(source.text, { baseUrl, includeVisibleText: true });
|
|
355
|
+
const documentIncomplete = options.sourceTruncated === true || source.truncated
|
|
356
|
+
|| parsedStructure.inputLimitReached || parsedStructure.tagLimitReached;
|
|
357
|
+
const treeIncomplete = documentIncomplete || parsedStructure.structureLimitReached;
|
|
358
|
+
const resolutionBase = parsedStructure.effectiveBaseUrl ?? baseUrl;
|
|
359
|
+
const normalized = normalizedMarkupAndStructure(parsedStructure, resolutionBase);
|
|
423
360
|
const text = visibleTextFingerprint(normalized.visibleText);
|
|
424
361
|
const structureSimilarity = simHash64(parsedStructure.structureTokens);
|
|
425
|
-
const forms = formStructureFingerprint(
|
|
362
|
+
const forms = formStructureFingerprint(parsedStructure, resolutionBase);
|
|
426
363
|
const resourceHosts = normalizedResourceHosts(options.resources);
|
|
427
364
|
const identifiers = normalizedIdentifiers(options.trackingIdentifiers, options.identifiersTruncated === true);
|
|
428
|
-
const truncated =
|
|
429
|
-
|| normalized.structureTruncated ||
|
|
430
|
-
|| parsedStructure.structureLimitReached || text?.truncated === true || forms?.truncated === true
|
|
365
|
+
const truncated = treeIncomplete || normalized.normalizedTruncated
|
|
366
|
+
|| normalized.structureTruncated || text?.truncated === true || forms?.truncated === true
|
|
431
367
|
|| resourceHosts.truncated || identifiers.truncated;
|
|
432
368
|
const limitations = [
|
|
433
369
|
'Fingerprints summarise capped static HTML and are comparison aids, not cryptographic proof of page authorship or intent.',
|
|
434
370
|
'Visible-text SimHash is a fuzzy similarity fingerprint and must not be treated as a cryptographic digest.',
|
|
435
|
-
'DOM-structure
|
|
371
|
+
'DOM-structure and form fingerprints describe parsed ancestry and explicit form references, not every browser form-owner repair, executed behaviour or proof of a shared author.',
|
|
436
372
|
];
|
|
437
373
|
if (source.truncated)
|
|
438
374
|
limitations.push(`Fingerprint input was capped at ${MAX_FINGERPRINT_SOURCE_BYTES} UTF-8 bytes.`);
|
|
@@ -453,24 +389,24 @@ function createPageFingerprints(html, options = {}) {
|
|
|
453
389
|
algorithm: 'sha256',
|
|
454
390
|
value: sha256(normalized.normalizedTokens.join('\n')),
|
|
455
391
|
tokenCount: normalized.normalizedTokens.length,
|
|
456
|
-
truncated: normalized.normalizedTruncated,
|
|
392
|
+
truncated: treeIncomplete || normalized.normalizedTruncated,
|
|
457
393
|
},
|
|
458
|
-
visibleText: text,
|
|
394
|
+
visibleText: text ? { ...text, truncated: treeIncomplete || text.truncated } : null,
|
|
459
395
|
domStructure: {
|
|
460
396
|
algorithm: 'sha256',
|
|
461
397
|
value: sha256(normalized.structureTokens.join('\n')),
|
|
462
398
|
nodeCount: normalized.structureTokens.length,
|
|
463
|
-
parser:
|
|
464
|
-
truncated: normalized.structureTruncated,
|
|
399
|
+
parser: PAGE_FINGERPRINT_PARSERS[PAGE_FINGERPRINT_VERSION],
|
|
400
|
+
truncated: treeIncomplete || normalized.structureTruncated,
|
|
465
401
|
similarity: structureSimilarity ? {
|
|
466
402
|
algorithm: 'simhash64-v1',
|
|
467
403
|
value: structureSimilarity.value,
|
|
468
404
|
tokenCount: parsedStructure.structureTokens.length,
|
|
469
405
|
featureCount: structureSimilarity.featureCount,
|
|
470
|
-
truncated:
|
|
406
|
+
truncated: treeIncomplete,
|
|
471
407
|
} : null,
|
|
472
408
|
},
|
|
473
|
-
formStructure: forms,
|
|
409
|
+
formStructure: forms ? { ...forms, truncated: documentIncomplete || forms.truncated } : null,
|
|
474
410
|
resourceHosts,
|
|
475
411
|
identifiers,
|
|
476
412
|
complete: !truncated,
|
|
@@ -24,13 +24,17 @@ function addFinding(findings, role, label, confidence, evidence) {
|
|
|
24
24
|
}
|
|
25
25
|
function analyzePageRole(input = {}) {
|
|
26
26
|
const htmlAnalysis = input.htmlAnalysis ?? analyzeStaticHtml(input.html);
|
|
27
|
-
const
|
|
27
|
+
const elements = htmlAnalysis.elements.filter((element) => element.html && element.name !== 'template');
|
|
28
|
+
// Reuse native element evidence instead of allocating and scanning a second,
|
|
29
|
+
// independently capped reconstruction of the same document.
|
|
30
|
+
const marker = (pattern) => elements.some((element) => pattern.test(element.name)
|
|
31
|
+
|| element.attributes.some(({ name, value }) => pattern.test(name) || pattern.test(value.toLowerCase())));
|
|
28
32
|
const title = boundedLowercase(input.pageTitle, MAX_ROLE_TITLE_CHARS);
|
|
29
33
|
const activityStatus = boundedLowercase(input.activityStatus, 40);
|
|
30
34
|
const forms = htmlAnalysis.forms;
|
|
31
35
|
const findings = [];
|
|
32
36
|
const challengeEvidence = [];
|
|
33
|
-
if (/(?:captcha|recaptcha|hcaptcha|turnstile|cf-chl|challenge-form|verify-human)/u
|
|
37
|
+
if (marker(/(?:captcha|recaptcha|hcaptcha|turnstile|cf-chl|challenge-form|verify-human)/u)) {
|
|
34
38
|
challengeEvidence.push('Static challenge or human-verification marker observed');
|
|
35
39
|
}
|
|
36
40
|
if (/(?:just a moment|checking your browser|verify you are human|access denied)/u.test(title)) {
|
|
@@ -43,19 +47,19 @@ function analyzePageRole(input = {}) {
|
|
|
43
47
|
if (forms.categories.username > 0 || forms.categories.email > 0) {
|
|
44
48
|
authenticationEvidence.push('Username or email-purpose input observed');
|
|
45
49
|
}
|
|
46
|
-
if (/(?:login|log-in|signin|sign-in|authentication|current-password|new-password)/u
|
|
50
|
+
if (marker(/(?:login|log-in|signin|sign-in|authentication|current-password|new-password)/u)) {
|
|
47
51
|
authenticationEvidence.push('Static authentication marker observed');
|
|
48
52
|
}
|
|
49
53
|
addFinding(findings, 'authentication', 'Authentication', forms.categories.password > 0 ? 'high' : 'medium', authenticationEvidence);
|
|
50
54
|
const commerceEvidence = [];
|
|
51
55
|
if (forms.categories.payment > 0)
|
|
52
56
|
commerceEvidence.push('Payment-purpose input observed');
|
|
53
|
-
if (/(?:checkout|shopping-cart|add-to-cart|product-price|woocommerce|shopify-payment)/u
|
|
57
|
+
if (marker(/(?:checkout|shopping-cart|add-to-cart|product-price|woocommerce|shopify-payment)/u)) {
|
|
54
58
|
commerceEvidence.push('Static commerce or checkout marker observed');
|
|
55
59
|
}
|
|
56
60
|
addFinding(findings, 'commerce', 'Commerce', forms.categories.payment > 0 ? 'high' : 'medium', commerceEvidence);
|
|
57
61
|
const supportEvidence = [];
|
|
58
|
-
if (/(?:contact|support|helpdesk|customer-service)/u
|
|
62
|
+
if (marker(/(?:contact|support|helpdesk|customer-service)/u)) {
|
|
59
63
|
supportEvidence.push('Static support or contact marker observed');
|
|
60
64
|
}
|
|
61
65
|
if (forms.formsObserved > 0 && forms.categories.password === 0 && forms.categories.payment === 0) {
|
|
@@ -65,14 +69,14 @@ function analyzePageRole(input = {}) {
|
|
|
65
69
|
const parkedEvidence = [];
|
|
66
70
|
if (activityStatus === 'parked')
|
|
67
71
|
parkedEvidence.push('Existing activity analysis classified the page as parked');
|
|
68
|
-
if (/(?:domain-for-sale|buy-this-domain|parking-page|sedoparking)/u
|
|
72
|
+
if (marker(/(?:domain-for-sale|buy-this-domain|parking-page|sedoparking)/u)) {
|
|
69
73
|
parkedEvidence.push('Static domain-sale or parking marker observed');
|
|
70
74
|
}
|
|
71
75
|
addFinding(findings, 'parked_sale', 'Parked or for sale', activityStatus === 'parked' ? 'high' : 'medium', parkedEvidence);
|
|
72
76
|
const contentEvidence = [];
|
|
73
|
-
if (
|
|
77
|
+
if (elements.some(({ name }) => name === 'article' || name === 'main'))
|
|
74
78
|
contentEvidence.push('Semantic article or main-content element observed');
|
|
75
|
-
if (
|
|
79
|
+
if (elements.some(({ name }) => name === 'h1' || name === 'h2'))
|
|
76
80
|
contentEvidence.push('Static heading structure observed');
|
|
77
81
|
addFinding(findings, 'content', 'Content or publication', contentEvidence.length > 1 ? 'medium' : 'low', contentEvidence);
|
|
78
82
|
if (!findings.length) {
|
package/lib/perceptual-hash.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
//
|
|
9
9
|
// Pure JS, no external dependency and no native bindings, so it runs the same
|
|
10
10
|
// on the serverless (Netlify) and self-hosted (Express) paths. That means
|
|
11
|
-
// decoding the image ourselves:
|
|
11
|
+
// decoding the image ourselves: the runtime has no built-in image decoder, only
|
|
12
12
|
// zlib (used here for PNG inflate). We support the favicon formats that
|
|
13
13
|
// actually occur in practice - PNG, and PNG- or BMP-encoded ICO - and return
|
|
14
14
|
// null for anything else (GIF/JPEG/SVG), which simply falls back to
|
|
@@ -23,8 +23,8 @@
|
|
|
23
23
|
import * as zlib from 'node:zlib';
|
|
24
24
|
import { hammingDistanceHex, isInformativePerceptualHash, } from "./perceptual-hash-comparison.mjs";
|
|
25
25
|
// Bound the decode work on attacker-controlled image bytes. Real favicons top
|
|
26
|
-
// out around 256x256;
|
|
27
|
-
//
|
|
26
|
+
// out around 256x256; larger inputs above this ceiling remain exact-only
|
|
27
|
+
// evidence rather than causing unbounded pixel allocation.
|
|
28
28
|
const MAX_DIM = 1024;
|
|
29
29
|
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
30
30
|
function byteAt(bytes, index) {
|
|
@@ -70,14 +70,18 @@ function decodePng(buf) {
|
|
|
70
70
|
const dataStart = offset + 8;
|
|
71
71
|
const dataEnd = dataStart + length;
|
|
72
72
|
if (dataEnd + 4 > buf.length)
|
|
73
|
-
|
|
73
|
+
return null;
|
|
74
74
|
const data = buf.subarray(dataStart, dataEnd);
|
|
75
75
|
if (type === 'IHDR') {
|
|
76
|
+
if (length !== 13 || width !== 0 || offset !== 8)
|
|
77
|
+
return null;
|
|
76
78
|
width = buf.readUInt32BE(dataStart);
|
|
77
79
|
height = buf.readUInt32BE(dataStart + 4);
|
|
78
80
|
bitDepth = byteAt(data, 8);
|
|
79
81
|
colorType = byteAt(data, 9);
|
|
80
82
|
interlace = byteAt(data, 12);
|
|
83
|
+
if (byteAt(data, 10) !== 0 || byteAt(data, 11) !== 0)
|
|
84
|
+
return null;
|
|
81
85
|
}
|
|
82
86
|
else if (type === 'PLTE') {
|
|
83
87
|
palette = data;
|
|
@@ -100,6 +104,13 @@ function decodePng(buf) {
|
|
|
100
104
|
return null;
|
|
101
105
|
if (colorType === 3 && !palette)
|
|
102
106
|
return null;
|
|
107
|
+
if (palette && (palette.length < 3 || palette.length > 768 || palette.length % 3 !== 0))
|
|
108
|
+
return null;
|
|
109
|
+
if (transparency && ((colorType === 0 && transparency.length !== 2)
|
|
110
|
+
|| (colorType === 2 && transparency.length !== 6)
|
|
111
|
+
|| (colorType === 3 && (!palette || transparency.length > palette.length / 3))
|
|
112
|
+
|| colorType === 4 || colorType === 6))
|
|
113
|
+
return null;
|
|
103
114
|
if (idatParts.length === 0)
|
|
104
115
|
return null;
|
|
105
116
|
// A valid non-interlaced 8-bit PNG decompresses to exactly one filter byte
|
|
@@ -162,6 +173,8 @@ function decodePng(buf) {
|
|
|
162
173
|
let alpha = 255;
|
|
163
174
|
if (colorType === 0) {
|
|
164
175
|
r = g = b = byteAt(recon, s);
|
|
176
|
+
if (transparency && r === transparency.readUInt16BE(0))
|
|
177
|
+
alpha = 0;
|
|
165
178
|
}
|
|
166
179
|
else if (colorType === 4) {
|
|
167
180
|
r = g = b = byteAt(recon, s);
|
|
@@ -171,6 +184,9 @@ function decodePng(buf) {
|
|
|
171
184
|
r = byteAt(recon, s);
|
|
172
185
|
g = byteAt(recon, s + 1);
|
|
173
186
|
b = byteAt(recon, s + 2);
|
|
187
|
+
if (transparency && r === transparency.readUInt16BE(0)
|
|
188
|
+
&& g === transparency.readUInt16BE(2) && b === transparency.readUInt16BE(4))
|
|
189
|
+
alpha = 0;
|
|
174
190
|
}
|
|
175
191
|
else if (colorType === 6) {
|
|
176
192
|
r = byteAt(recon, s);
|
|
@@ -200,13 +216,13 @@ function decodePng(buf) {
|
|
|
200
216
|
// BITMAPINFOHEADER, an optional palette (for <=8-bit depths), then bottom-up
|
|
201
217
|
// pixel rows. Supports the depths favicons actually ship: 1/4/8-bit
|
|
202
218
|
// palettized and 24/32-bit truecolour. The stored height is doubled (it
|
|
203
|
-
// includes the 1-bpp AND transparency mask)
|
|
204
|
-
//
|
|
219
|
+
// includes the 1-bpp AND transparency mask). Non-alpha and legacy all-zero
|
|
220
|
+
// alpha images use that mask; meaningful 32-bit alpha remains authoritative.
|
|
205
221
|
function decodeDib(buf) {
|
|
206
222
|
if (buf.length < 40)
|
|
207
223
|
return null;
|
|
208
224
|
const headerSize = buf.readUInt32LE(0);
|
|
209
|
-
if (headerSize < 40)
|
|
225
|
+
if (headerSize < 40 || headerSize > buf.length)
|
|
210
226
|
return null;
|
|
211
227
|
const width = buf.readInt32LE(4);
|
|
212
228
|
const rawHeight = buf.readInt32LE(8);
|
|
@@ -216,7 +232,9 @@ function decodeDib(buf) {
|
|
|
216
232
|
return null; // BI_RGB only
|
|
217
233
|
if (![1, 4, 8, 24, 32].includes(bitCount))
|
|
218
234
|
return null;
|
|
219
|
-
|
|
235
|
+
if (rawHeight <= 0 || rawHeight % 2 !== 0 || buf.readUInt16LE(12) !== 1)
|
|
236
|
+
return null;
|
|
237
|
+
const height = rawHeight / 2;
|
|
220
238
|
if (width < 1 || height < 1 || width > MAX_DIM || height > MAX_DIM)
|
|
221
239
|
return null;
|
|
222
240
|
// Palette (BGRA quads) for indexed depths, sitting between the header and
|
|
@@ -235,6 +253,11 @@ function decodeDib(buf) {
|
|
|
235
253
|
const rowSize = Math.floor((bitCount * width + 31) / 32) * 4; // padded to 4 bytes
|
|
236
254
|
if (pixelStart + rowSize * height > buf.length)
|
|
237
255
|
return null;
|
|
256
|
+
const maskStart = pixelStart + rowSize * height;
|
|
257
|
+
const maskRowSize = Math.ceil(width / 32) * 4;
|
|
258
|
+
const maskPresent = maskStart + maskRowSize * height <= buf.length;
|
|
259
|
+
if (bitCount !== 32 && !maskPresent)
|
|
260
|
+
return null;
|
|
238
261
|
const paletteColor = (index) => {
|
|
239
262
|
if (!palette || index >= palette.count)
|
|
240
263
|
return [0, 0, 0];
|
|
@@ -282,11 +305,16 @@ function decodeDib(buf) {
|
|
|
282
305
|
}
|
|
283
306
|
// Many legacy 32-bit BMP-in-ICO payloads leave the alpha plane all-zero and
|
|
284
307
|
// rely on the separate 1-bit AND mask for transparency. Trusting those alpha
|
|
285
|
-
// bytes literally would composite the whole icon to white
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
308
|
+
// bytes literally would composite the whole icon to white. Restore opacity
|
|
309
|
+
// only where the legacy mask permits it. No mask plus meaningful 32-bit
|
|
310
|
+
// alpha is valid; an omitted legacy mask retains the opaque fallback.
|
|
311
|
+
if (bitCount !== 32 || !sawNonzeroAlpha) {
|
|
312
|
+
for (let y = 0; y < height; y += 1) {
|
|
313
|
+
for (let x = 0; x < width; x += 1) {
|
|
314
|
+
const mask = maskPresent ? byteAt(buf, maskStart + (height - 1 - y) * maskRowSize + (x >> 3)) : 0;
|
|
315
|
+
pixels[(y * width + x) * 4 + 3] = mask & (0x80 >> (x & 7)) ? 0 : 255;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
290
318
|
}
|
|
291
319
|
return { width, height, pixels };
|
|
292
320
|
}
|
|
@@ -428,4 +456,5 @@ function inspectDecodedImage(buf) {
|
|
|
428
456
|
// produced by the optional local capture package. Keep the generic name as an
|
|
429
457
|
// alias so the evidence contract does not imply that a screenshot is a favicon.
|
|
430
458
|
const imagePerceptualHash = faviconPerceptualHash;
|
|
431
|
-
|
|
459
|
+
// Pixel consumers share the same strict byte/dimension admission as hashing.
|
|
460
|
+
export { faviconPerceptualHash, hammingDistanceHex, imagePerceptualHash, inspectDecodedImage, decodeImage as decodeBoundedImagePixels, dHash as imagePixelsPerceptualHash };
|